Promote project operations and retain production preview frame
12 files+146−1120/12 viewed
| 597 | 597 | key={page} | |
| 598 | 598 | to={link.path ? `${base}/${link.path}` : base} | |
| 599 | 599 | current={at === page} | |
| 600 | + | soon={page !== "overview" && PROJECT_PAGE_LINKS[page].soon} | |
| 600 | 601 | count={page === "issues" ? counts?.issues : page === "pulls" ? counts?.pulls : undefined} | |
| 601 | 602 | > | |
| 602 | 603 | {link.label} |
| 11 | 11 | * deploy on g1t.page, or of the address of production deployed | |
| 12 | 12 | * elsewhere), else an image someone uploaded (lib/production-image.ts | |
| 13 | 13 | * `cardSources`). Each source is tried in turn, the next when one does not | |
| 14 | − | * load. Until one has loaded, or when there is none, a quiet frame with | |
| 15 | − | * the address stands in; to someone who may change the project's | |
| 14 | + | * load. The browser frame and address stay visible in every state. | |
| 15 | + | * Until an image loads, a quiet page stands in; to someone who may change the project's | |
| 16 | 16 | * settings it offers to add an image. | |
| 17 | 17 | */ | |
| 18 | 18 | export function ProductionShot({ | |
| ⋯ | |||
| 72 | 72 | const frame = ( | |
| 73 | 73 | <span | |
| 74 | 74 | className={cn( | |
| 75 | − | "relative block aspect-16/10 w-full overflow-hidden rounded-lg border border-line bg-bg", | |
| 75 | + | "block w-full overflow-hidden rounded-lg border border-line bg-bg", | |
| 76 | 76 | href && "transition-colors group-hover:border-line-strong", | |
| 77 | 77 | )} | |
| 78 | 78 | > | |
| 79 | − | {!loaded && !under && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />} | |
| 80 | − | {under && ( | |
| 81 | − | <img | |
| 82 | − | key={under.src} | |
| 83 | − | src={under.src} | |
| 84 | − | alt="" | |
| 85 | − | aria-hidden="true" | |
| 86 | − | width={1280} | |
| 87 | − | height={800} | |
| 88 | − | className="absolute inset-0 size-full object-cover object-top" | |
| 89 | − | /> | |
| 90 | − | )} | |
| 91 | − | {source && ( | |
| 92 | − | <img | |
| 93 | − | key={source.src} | |
| 94 | − | ref={image} | |
| 95 | − | src={source.src} | |
| 96 | − | alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`} | |
| 97 | − | width={1280} | |
| 98 | − | height={800} | |
| 99 | − | decoding="async" | |
| 100 | − | onLoad={() => setLoaded(true)} | |
| 101 | − | onError={() => { | |
| 102 | − | setLoaded(false); | |
| 103 | − | setAt((index) => index + 1); | |
| 104 | − | }} | |
| 105 | − | className={cn( | |
| 106 | − | "absolute inset-0 size-full object-cover object-top transition-opacity duration-300", | |
| 107 | − | loaded ? "opacity-100" : "opacity-0", | |
| 108 | − | )} | |
| 109 | − | /> | |
| 110 | − | )} | |
| 79 | + | <span className="flex h-7 items-center gap-1.5 border-b border-line bg-surface px-2.5"> | |
| 80 | + | <span aria-hidden="true" className="flex shrink-0 gap-1.5"> | |
| 81 | + | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 82 | + | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 83 | + | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 84 | + | </span> | |
| 85 | + | {label && <span className="ml-1.5 min-w-0 flex-1 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-muted">{label}</span>} | |
| 86 | + | </span> | |
| 87 | + | <span className="relative block aspect-16/10 w-full overflow-hidden"> | |
| 88 | + | {!loaded && !under && <Placeholder waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />} | |
| 89 | + | {under && ( | |
| 90 | + | <img | |
| 91 | + | key={under.src} | |
| 92 | + | src={under.src} | |
| 93 | + | alt="" | |
| 94 | + | aria-hidden="true" | |
| 95 | + | width={1280} | |
| 96 | + | height={800} | |
| 97 | + | className="absolute inset-0 size-full object-cover object-top" | |
| 98 | + | /> | |
| 99 | + | )} | |
| 100 | + | {source && ( | |
| 101 | + | <img | |
| 102 | + | key={source.src} | |
| 103 | + | ref={image} | |
| 104 | + | src={source.src} | |
| 105 | + | alt={source.kind === "screenshot" ? `Screenshot of ${label}` : `Image of ${label}`} | |
| 106 | + | width={1280} | |
| 107 | + | height={800} | |
| 108 | + | decoding="async" | |
| 109 | + | onLoad={() => setLoaded(true)} | |
| 110 | + | onError={() => { | |
| 111 | + | setLoaded(false); | |
| 112 | + | setAt((index) => index + 1); | |
| 113 | + | }} | |
| 114 | + | className={cn( | |
| 115 | + | "absolute inset-0 size-full object-cover object-top transition-opacity duration-300", | |
| 116 | + | loaded ? "opacity-100" : "opacity-0", | |
| 117 | + | )} | |
| 118 | + | /> | |
| 119 | + | )} | |
| 120 | + | </span> | |
| 111 | 121 | </span> | |
| 112 | 122 | ); | |
| 113 | 123 | ||
| ⋯ | |||
| 134 | 144 | ); | |
| 135 | 145 | } | |
| 136 | 146 | ||
| 137 | − | /** The frame without a picture: a browser's bar with the address, and faint lines of a page. */ | |
| 138 | − | function Placeholder({ label, waiting, note }: { label: string; waiting: string | null; note: string | null }) { | |
| 147 | + | /** Faint page content inside the persistent browser frame while no picture is available. */ | |
| 148 | + | function Placeholder({ waiting, note }: { waiting: string | null; note: string | null }) { | |
| 139 | 149 | const line = waiting ?? note; | |
| 140 | 150 | return ( | |
| 141 | 151 | <span className="absolute inset-0 flex flex-col"> | |
| 142 | − | <span className="flex items-center gap-1.5 border-b border-line bg-surface px-2.5 py-1.5"> | |
| 143 | − | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 144 | − | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 145 | − | <span className="size-1.5 rounded-full bg-line-strong" /> | |
| 146 | − | {label && <span className="ml-1.5 truncate rounded bg-bg px-2 py-px font-mono text-[0.625rem] text-faint">{label}</span>} | |
| 147 | − | </span> | |
| 148 | 152 | <span className="relative flex grow flex-col gap-2 p-3.5"> | |
| 149 | 153 | <span className="h-2 w-2/5 rounded-full bg-line/80" /> | |
| 150 | 154 | <span className="h-1.5 w-4/5 rounded-full bg-line/50" /> | |
| 3 | 3 | /** Each of a project's settings pages: its name and what it is for. */ | |
| 4 | 4 | const PAGES: Record<string, { title: string; about: string }> = { | |
| 5 | 5 | "": { title: "General", about: "The project's name, where its code lives, what it is for, and archiving, moving or deleting it." }, | |
| 6 | − | deployments: { title: "Deployments", about: "How the project builds, where it runs, and its previews." }, | |
| 6 | + | deployments: { title: "Build configuration", about: "How the project builds, where it runs, and its previews." }, | |
| 7 | 7 | domains: { title: "Domains", about: "Addresses of your own for the production deployment." }, | |
| 8 | 8 | guardrails: { title: "Guardrails", about: "How long this project's runs may take." }, | |
| 9 | 9 | repository: { title: "Repository", about: "Its name, details and default branch, and who can see it." }, | |
| ⋯ | |||
| 11 | 11 | keys: { title: "Deploy keys", about: "SSH keys that let a machine clone this repository, or push to it, and reach nothing else." }, | |
| 12 | 12 | branches: { title: "Branches and merging", about: "How pull requests merge, and who owns which files." }, | |
| 13 | 13 | rules: { title: "Rules", about: "Rulesets: what may happen to branches and tags, what a pull request needs before it merges, and how the rules judged each push and merge." }, | |
| 14 | − | secrets: { title: "Secrets and variables", about: "Values workflows, builds and deployments read at run time." }, | |
| 14 | + | secrets: { title: "Environment variables", about: "Secrets and configuration for workflows, builds and running apps. Scope values to production, previews or all environments." }, | |
| 15 | 15 | actions: { title: "Actions", about: "What workflows' tokens may do, and whose pull requests' runs wait for approval." }, | |
| 16 | 16 | environments: { title: "Environments", about: "Protection rules for the environments jobs deploy to: reviews, wait timers and branches." }, | |
| 17 | 17 | runners: { title: "Runners", about: "Machines of your own for this project's workflow jobs and sandbox runs." }, | |
| 67 | 67 | <div className="max-w-5xl"> | |
| 68 | 68 | <header className="flex flex-wrap items-start justify-between gap-4"> | |
| 69 | 69 | <div> | |
| 70 | − | <h2 className="text-lg font-semibold tracking-tight">Secrets and variables</h2> | |
| 70 | + | <h1 className="text-lg font-semibold tracking-tight">{scope === "project" ? "Environment variables" : "Secrets and variables"}</h1> | |
| 71 | 71 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 72 | 72 | One list for everything that reads them. Each row says which environments it applies to and whether{" "} | |
| 73 | 73 | <strong className="font-medium text-fg">workflows</strong> (as <code className="text-fg">secrets.KEY</code>{" "} |
| 1 | − | import { Activity, BarChart3, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Database, Activity, BarChart3, BrickWall, Building2, ChartLine, Flag, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Repeat, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, Ticket, TrendingUp, UserPlus, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 66 | 66 | import { useChatSidebar } from "./chat/actions"; | |
| 67 | 67 | import { unreadTotals } from "../lib/chat"; | |
| 68 | 68 | import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access"; | |
| 69 | − | import { projectPages } from "../lib/chrome"; | |
| 69 | + | import { PROJECT_OPERATIONS, PROJECT_PAGE_LINKS, projectPageAt, projectPages } from "../lib/chrome"; | |
| 70 | 70 | import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings"; | |
| 71 | 71 | import { type PinnableApp, sidebarCookie } from "../lib/apps"; | |
| 72 | 72 | import { REASON_FILTERS, inboxReason } from "../lib/inbox"; | |
| ⋯ | |||
| 848 | 848 | <Rule /> | |
| 849 | 849 | <NavRows> | |
| 850 | 850 | {shows.has("deployments") ? ( | |
| 851 | − | <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}> | |
| 851 | + | <SidebarLink to={`${base}/deployments`} also={[`${base}/settings/deployments`, ...soonPaths(base, "Deployments")]} icon={<Rocket size={15} />}> | |
| 852 | 852 | Deployments | |
| 853 | 853 | </SidebarLink> | |
| 854 | 854 | ) : null} | |
| ⋯ | |||
| 873 | 873 | Insights | |
| 874 | 874 | </SidebarLink> | |
| 875 | 875 | </NavRows> | |
| 876 | + | {repo.member && ( | |
| 877 | + | <> | |
| 878 | + | <Rule /> | |
| 879 | + | <NavRows> | |
| 880 | + | {PROJECT_OPERATIONS.filter((page) => shows.has(page)).map((page) => { | |
| 881 | + | const link = PROJECT_PAGE_LINKS[page]; | |
| 882 | + | const icons = { secrets: <KeyRound size={15} />, environments: <Layers size={15} />, domains: <Globe size={15} />, runners: <ServerCog size={15} />, webhooks: <Webhook size={15} /> }; | |
| 883 | + | return <SidebarLink key={page} to={`${base}/${link.path}`} icon={icons[page]}>{link.label}</SidebarLink>; | |
| 884 | + | })} | |
| 885 | + | <ProjectSoonRow base={base} feature="storage" icon={<Database size={15} />} /> | |
| 886 | + | </NavRows> | |
| 887 | + | </> | |
| 888 | + | )} | |
| 876 | 889 | {(repo.settings ?? shows.has("settings")) && ( | |
| 877 | 890 | <> | |
| 878 | 891 | <Rule /> | |
| 879 | 892 | <NavRows> | |
| 880 | − | <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />} drill> | |
| 893 | + | <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />} drill> | |
| 881 | 894 | Settings | |
| 882 | 895 | </SidebarLink> | |
| 883 | 896 | </NavRows> | |
| ⋯ | |||
| 888 | 901 | } | |
| 889 | 902 | ||
| 890 | 903 | /** | |
| 891 | − | * A project's settings, drilled into from its list: what is about running | |
| 892 | − | * it, then its sandboxes, then its code and who can reach it. The way back | |
| 893 | − | * leads to the project. | |
| 904 | + | * Administrative settings: repository policy, access and workflow permissions. | |
| 905 | + | * Day-to-day operations stay in the main project menu. | |
| 894 | 906 | */ | |
| 895 | 907 | function RepoSettingsMenu({ repo }: { repo: MenuRepo }) { | |
| 896 | 908 | const base = `/${repo.namespace}/${repo.name}`; | |
| 897 | 909 | // Each page shows to the roles that can use it, once the role is known. | |
| 898 | 910 | const shows = (page: string) => !repo.can || repo.can[SETTINGS_CAPABILITY[page] ?? "administer"]; | |
| 899 | − | const running = shows("deployments"); | |
| 900 | 911 | const guarded = shows("guardrails"); | |
| 901 | 912 | return ( | |
| 902 | 913 | <nav aria-label={`${repo.namespace}/${repo.name} settings`} className={PANEL}> | |
| ⋯ | |||
| 907 | 918 | General | |
| 908 | 919 | </SidebarLink> | |
| 909 | 920 | </NavRows> | |
| 910 | − | )} | |
| 911 | − | {running && ( | |
| 912 | − | <> | |
| 913 | − | {shows("") && <Rule />} | |
| 914 | − | <NavRows className={shows("") ? undefined : "mt-2"}> | |
| 915 | − | <SidebarLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}> | |
| 916 | − | Deployments | |
| 917 | − | </SidebarLink> | |
| 918 | − | <SidebarLink to={`${base}/settings/domains`} icon={<Globe size={15} />}> | |
| 919 | − | Domains | |
| 920 | − | </SidebarLink> | |
| 921 | − | </NavRows> | |
| 922 | − | </> | |
| 923 | 921 | )} | |
| 924 | 922 | {guarded && ( | |
| 925 | 923 | <> | |
| ⋯ | |||
| 960 | 958 | Rules | |
| 961 | 959 | </SidebarLink> | |
| 962 | 960 | )} | |
| 963 | − | {shows("secrets") && ( | |
| 964 | − | <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}> | |
| 965 | − | Secrets and variables | |
| 966 | − | </SidebarLink> | |
| 967 | − | )} | |
| 961 | + | ||
| 968 | 962 | {shows("actions") && ( | |
| 969 | 963 | <SidebarLink to={`${base}/settings/actions`} icon={<PlayCircle size={15} />}> | |
| 970 | 964 | Actions | |
| 971 | 965 | </SidebarLink> | |
| 972 | 966 | )} | |
| 973 | − | {shows("environments") && ( | |
| 974 | − | <SidebarLink to={`${base}/settings/environments`} icon={<Layers size={15} />}> | |
| 975 | − | Environments | |
| 976 | − | </SidebarLink> | |
| 977 | − | )} | |
| 978 | − | {shows("runners") && ( | |
| 979 | − | <SidebarLink to={`${base}/settings/runners`} icon={<ServerCog size={15} />}> | |
| 980 | − | Runners | |
| 981 | − | </SidebarLink> | |
| 982 | − | )} | |
| 983 | − | {shows("webhooks") && ( | |
| 984 | − | <SidebarLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}> | |
| 985 | − | Webhooks | |
| 986 | − | </SidebarLink> | |
| 987 | − | )} | |
| 967 | + | ||
| 968 | + | ||
| 969 | + | ||
| 988 | 970 | </NavRows> | |
| 989 | 971 | </nav> | |
| 990 | 972 | ); | |
| ⋯ | |||
| 1066 | 1048 | !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) && | |
| 1067 | 1049 | (!lost || sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! })); | |
| 1068 | 1050 | // A project's settings, one level further in. | |
| 1069 | − | const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target); | |
| 1051 | + | const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target) && projectPageAt(target.split("/").slice(3).join("/")) === "settings"; | |
| 1070 | 1052 | // The repository the menu is for: the one loaded if it is the one being | |
| 1071 | 1053 | // gone to, else what the address says, at once. | |
| 1072 | 1054 | const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null; | |
| ⋯ | |||
| 1662 | 1644 | else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true }); | |
| 1663 | 1645 | // A Soon page names what is coming. | |
| 1664 | 1646 | else if (third === "soon" && fourth && roadmapItem(fourth)) trail.push({ label: roadmapItem(fourth)!.title, to: `${repo}/soon/${fourth}` }); | |
| 1647 | + | else if (third === "settings" && fourth && projectPageAt(`settings/${fourth}`) !== "settings" && projectPageAt(`settings/${fourth}`) != null) { | |
| 1648 | + | const page = projectPageAt(`settings/${fourth}`) as keyof typeof PROJECT_PAGE_LINKS; | |
| 1649 | + | trail.push({ label: fourth === "deployments" ? "Build configuration" : PROJECT_PAGE_LINKS[page].label, to: `${repo}/settings/${fourth}` }); | |
| 1650 | + | } | |
| 1665 | 1651 | else if (third && SECTIONS[third]) { | |
| 1666 | 1652 | trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` }); | |
| 1667 | 1653 | // A settings page names which one: Settings / Secrets and variables. | |
| ⋯ | |||
| 1807 | 1793 | ...(repo.member | |
| 1808 | 1794 | ? [ | |
| 1809 | 1795 | { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> }, | |
| 1810 | − | { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> }, | |
| 1796 | + | { label: "Environment variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> }, | |
| 1811 | 1797 | { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> }, | |
| 1812 | 1798 | { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> }, | |
| 1813 | 1799 | ] | |
| 80 | 80 | assert.equal(projectPageAt("observability/logs"), "observability"); | |
| 81 | 81 | assert.equal(projectPageAt("pulse"), null); | |
| 82 | 82 | }); | |
| 83 | + | ||
| 84 | + | ||
| 85 | + | test("project operations keep their place in navigation and require integration access", () => { | |
| 86 | + | const operations = ["secrets", "environments", "domains", "runners", "webhooks"] as const; | |
| 87 | + | for (const operation of operations) { | |
| 88 | + | assert.equal(projectPageAt(`settings/${operation}`), operation); | |
| 89 | + | assert.equal(projectPageAt(`settings/${operation}/detail`), operation); | |
| 90 | + | assert.ok(!projectPages(false).includes(operation)); | |
| 91 | + | assert.ok(!projectPages(true, { manage_integrations: false }).includes(operation)); | |
| 92 | + | assert.ok(projectPages(true, { manage_integrations: true }).includes(operation)); | |
| 93 | + | } | |
| 94 | + | assert.equal(projectPageAt("settings/deployments"), "deployments"); | |
| 95 | + | assert.equal(projectPageAt("settings/access"), "settings"); | |
| 96 | + | assert.equal(projectPageAt("soon/storage"), "storage"); | |
| 97 | + | assert.ok(projectPages(true).includes("storage")); | |
| 98 | + | assert.ok(!projectPages(false).includes("storage")); | |
| 99 | + | }); |
| 68 | 68 | | "observability" | |
| 69 | 69 | | "security" | |
| 70 | 70 | | "insights" | |
| 71 | + | | "secrets" | |
| 72 | + | | "environments" | |
| 73 | + | | "domains" | |
| 74 | + | | "runners" | |
| 75 | + | | "webhooks" | |
| 76 | + | | "storage" | |
| 71 | 77 | | "settings"; | |
| 72 | 78 | ||
| 73 | 79 | /** | |
| 74 | 80 | * What a page needs beyond a role on the repository, once what the viewer | |
| 75 | 81 | * may do is known: Security's findings are for people who can push. | |
| 76 | 82 | */ | |
| 77 | − | export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { security: "push" }; | |
| 83 | + | export const PROJECT_OPERATIONS = ["secrets", "environments", "domains", "runners", "webhooks"] as const; | |
| 84 | + | ||
| 85 | + | export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { | |
| 86 | + | security: "push", | |
| 87 | + | secrets: "manage_integrations", environments: "manage_integrations", domains: "manage_integrations", | |
| 88 | + | runners: "manage_integrations", webhooks: "manage_integrations", | |
| 89 | + | }; | |
| 78 | 90 | ||
| 79 | 91 | /** | |
| 80 | 92 | * A project's menu, in order. Security and Settings are for people with a | |
| ⋯ | |||
| 94 | 106 | member && "observability", | |
| 95 | 107 | member && "security", | |
| 96 | 108 | "insights", | |
| 109 | + | ...PROJECT_OPERATIONS.map((page) => member && page), | |
| 110 | + | member && "storage", | |
| 97 | 111 | member && "settings", | |
| 98 | 112 | ]; | |
| 99 | 113 | return pages.filter( | |
| ⋯ | |||
| 112 | 126 | issues: { label: "Issues", path: "issues", also: ["milestones", "labels"] }, | |
| 113 | 127 | pulls: { label: "Pull requests", path: "pulls", also: ["pull", "work", "queue", "sessions"] }, | |
| 114 | 128 | actions: { label: "Workflows", path: "actions", also: [] }, | |
| 115 | − | deployments: { label: "Deployments", path: "deployments", also: [] }, | |
| 129 | + | deployments: { label: "Deployments", path: "deployments", also: ["settings/deployments"] }, | |
| 116 | 130 | observability: { label: "Observability", path: "observability", also: [] }, | |
| 117 | 131 | security: { label: "Security", path: "security", also: [] }, | |
| 118 | 132 | insights: { label: "Insights", path: "contributors", also: ["activity", "stargazers"] }, | |
| 133 | + | secrets: { label: "Environment variables", path: "settings/secrets", also: [] }, | |
| 134 | + | environments: { label: "Environments", path: "settings/environments", also: [] }, | |
| 135 | + | domains: { label: "Domains", path: "settings/domains", also: [] }, | |
| 136 | + | runners: { label: "Runners", path: "settings/runners", also: [] }, | |
| 137 | + | webhooks: { label: "Webhooks", path: "settings/webhooks", also: [] }, | |
| 138 | + | storage: { label: "Storage", path: "soon/storage", also: [], soon: true }, | |
| 119 | 139 | settings: { label: "Settings", path: "settings", also: [] }, | |
| 120 | 140 | }; | |
| 121 | 141 | ||
| 2 | 2 | import { readFileSync } from "node:fs"; | |
| 3 | 3 | import { test } from "node:test"; | |
| 4 | 4 | ||
| 5 | + | import { tabsFor } from "./project-nav.ts"; | |
| 5 | 6 | import { ROADMAP } from "./roadmap.ts"; | |
| 6 | 7 | ||
| 7 | 8 | test("code is not docs: a project never gets a Docs tab", () => { | |
| ⋯ | |||
| 10 | 11 | const nav = readFileSync(new URL("./project-nav.ts", import.meta.url), "utf8"); | |
| 11 | 12 | assert.doesNotMatch(nav, /label: "Docs"|soon\("Code"\)/); | |
| 12 | 13 | }); | |
| 14 | + | ||
| 15 | + | ||
| 16 | + | test("build configuration is discoverable under deployments only with integration access", () => { | |
| 17 | + | const writer = tabsFor("settings/deployments", true, { manage_integrations: true }); | |
| 18 | + | assert.ok(writer?.some((tab) => tab.path === "deployments")); | |
| 19 | + | assert.ok(writer?.some((tab) => tab.path === "settings/deployments")); | |
| 20 | + | assert.equal(tabsFor("deployments", false), null); | |
| 21 | + | assert.equal(tabsFor("deployments", true, { manage_integrations: false }), null); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("Storage is planned while implemented environments are no longer marked Soon", () => { | |
| 25 | + | assert.equal(ROADMAP.find((item) => item.key === "storage")?.section, "Project"); | |
| 26 | + | assert.equal(ROADMAP.some((item) => item.key === "environments"), false); | |
| 27 | + | }); | |
| 64 | 64 | }, | |
| 65 | 65 | { | |
| 66 | 66 | key: "Deployments", | |
| 67 | − | tabs: [{ label: "Deployments", path: "deployments" }, ...soon("Deployments")], | |
| 67 | + | tabs: [{ label: "Deployments", path: "deployments" }, { label: "Build configuration", path: "settings/deployments", members: true, needs: "manage_integrations" }, ...soon("Deployments")], | |
| 68 | 68 | }, | |
| 69 | 69 | { | |
| 70 | 70 | key: "Observability", |
| 32 | 32 | // Nothing: code is not docs. Docs is its own workspace mode, filterable | |
| 33 | 33 | // by project there, and a project never gets a Docs tab. | |
| 34 | 34 | ||
| 35 | − | // --- Deployments ---------------------------------------------------------- | |
| 35 | + | // --- Pages of their own in the project's sidebar ---------------------- | |
| 36 | 36 | { | |
| 37 | − | key: "environments", | |
| 38 | − | title: "Environments", | |
| 39 | − | section: "Deployments", | |
| 40 | − | summary: "Staging, production and others, with approvers and branch rules.", | |
| 41 | − | why: "Production and previews exist today. Environments add the rest: staging, QA, per-customer, each with its own variables, approvers and rules for what may deploy there.", | |
| 42 | − | plans: [ | |
| 43 | − | "Named environments with their own variables and secrets", | |
| 44 | − | "Required approvers before a deploy", | |
| 45 | − | "Branch rules: what may deploy where", | |
| 46 | − | "Promote a deployment from one environment to the next", | |
| 47 | − | ], | |
| 48 | − | today: { label: "Deployments", path: "deployments" }, | |
| 37 | + | key: "storage", | |
| 38 | + | title: "Storage", | |
| 39 | + | section: "Project", | |
| 40 | + | summary: "Configure databases and storage for your project.", | |
| 41 | + | why: "Manage the data services your app depends on alongside its deployments, with connections scoped to the right environment.", | |
| 42 | + | plans: ["Connect databases and storage to a project", "Configure connections for production and preview environments", "Manage connection credentials alongside environment variables"], | |
| 43 | + | today: { label: "Environment variables", path: "settings/secrets" }, | |
| 49 | 44 | }, | |
| 50 | − | ||
| 51 | − | // --- Pages of their own in the project's sidebar ---------------------- | |
| 52 | 45 | { | |
| 53 | 46 | key: "analytics", | |
| 54 | 47 | title: "Analytics", |
| 1 | 1 | import type { Route } from "./+types/secrets"; | |
| 2 | 2 | import { page } from "../../lib/meta"; | |
| 3 | − | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 4 | 3 | import { SecretsPanel } from "../../components/secrets"; | |
| 5 | 4 | import { actOnSecrets, loadSecrets } from "../../lib/secrets.server"; | |
| 6 | 5 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 7 | 6 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 8 | 7 | ||
| 9 | 8 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 10 | − | return page(args, { title: `Secrets and variables · ${params.owner}/${params.repo} · g1t` }); | |
| 9 | + | return page(args, { title: `Environment variables · ${params.owner}/${params.repo} · g1t` }); | |
| 11 | 10 | } | |
| 12 | 11 | ||
| 13 | 12 | const ownerOf = (params: { owner: string; repo: string }) => ({ repo: { namespace: params.owner, name: params.repo } }); | |
| ⋯ | |||
| 26 | 25 | return actOnSecrets(ownerOf(params), user, await request.formData(), page); | |
| 27 | 26 | } | |
| 28 | 27 | ||
| 29 | − | export default function RepoSecrets({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 28 | + | export default function RepoSecrets({ loaderData, actionData }: Route.ComponentProps) { | |
| 30 | 29 | return ( | |
| 31 | 30 | <div> | |
| 32 | − | <RepoSettingsHeading base={`/${params.owner}/${params.repo}`} /> | |
| 33 | 31 | <SecretsPanel data={loaderData} action={actionData} scope="project" manage /> | |
| 34 | 32 | </div> | |
| 35 | 33 | ); | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | /** Soon pages that have since been built, and where each is now. */ | |
| 20 | − | const BUILT: Record<string, string> = { releases: "releases", logs: "observability/logs", errors: "observability/errors" }; | |
| 20 | + | const BUILT: Record<string, string> = { environments: "settings/environments", releases: "releases", logs: "observability/logs", errors: "observability/errors" }; | |
| 21 | 21 | ||
| 22 | 22 | export function loader({ params }: Route.LoaderArgs) { | |
| 23 | 23 | const built = BUILT[params.feature]; |
Comments
Sign in to comment.