diff --git a/web/e2e/dependency-updates.spec.ts b/web/e2e/dependency-updates.spec.ts index 57a68f3..4bf4383 100644 --- a/web/e2e/dependency-updates.spec.ts +++ b/web/e2e/dependency-updates.spec.ts @@ -6,7 +6,7 @@ import { test, expect, freshProject } from "./fixtures"; test("dependency updates: turn on, run now, turn off", async ({ page }) => { const { project, web, remove } = await freshProject(page, "deps", { "package.json": '{ "dependencies": { "left-pad": "1.0.0" } }\n' }); try { - await page.goto(`${web}/-/settings`); + await page.goto(`${web}/-/settings/automation`); const section = page.locator("section.settings-section", { has: page.getByRole("heading", { name: "Dependency updates" }) }); await section.getByRole("checkbox", { name: /update dependencies/ }).check(); await section.getByRole("button", { name: "Save", exact: true }).click(); @@ -20,7 +20,7 @@ test("dependency updates: turn on, run now, turn off", async ({ page }) => { await expect(page.getByText(`renovate-bot-${project.id}`)).toBeVisible(); // a private registry: the password is never shown again - await page.goto(`${web}/-/settings`); + await page.goto(`${web}/-/settings/automation`); await section.getByLabel("Host").fill("npm.example.com"); await section.getByLabel("Type").fill("npm"); await section.getByLabel("Password or token").fill("e2e-npm-token"); diff --git a/web/e2e/everyday.spec.ts b/web/e2e/everyday.spec.ts index 222932c..86c3426 100644 --- a/web/e2e/everyday.spec.ts +++ b/web/e2e/everyday.spec.ts @@ -452,7 +452,7 @@ test("protect tags in settings; protected tags are marked", async ({ page }) => const pattern = `e2e-p${Date.now() % 100000}-*`; const tag = pattern.replace("*", "1"); try { - await page.goto(`${P}/-/settings`); + await page.goto(`${P}/-/settings/repository`); await page.getByLabel(/^Tag or pattern/).fill(pattern); await page.getByRole("button", { name: "Protect tags" }).click(); await expect(page.locator('[aria-label="Protected tags"]')).toContainText(pattern); diff --git a/web/e2e/rules.spec.ts b/web/e2e/rules.spec.ts index 1618268..d534e25 100644 --- a/web/e2e/rules.spec.ts +++ b/web/e2e/rules.spec.ts @@ -6,7 +6,7 @@ test("push rules are saved from settings, and a bad pattern is explained", async const csrf = await csrfHeaders(page); const project = await (await page.request.get(`/api/v1/resolve?path=${P.slice(1)}`)).json(); try { - await page.goto(`${P}/-/settings`); + await page.goto(`${P}/-/settings/repository`); const field = page.getByLabel("Commit messages must match"); await field.fill("("); await page.getByRole("button", { name: "Save push rules" }).click(); diff --git a/web/src/components/AuditEvents.tsx b/web/src/components/AuditEvents.tsx new file mode 100644 index 0000000..d967cfa --- /dev/null +++ b/web/src/components/AuditEvents.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api, timeAgo, type S } from "../api/client"; +import { Empty, Pager, Q } from "./ui"; + +/** + * The audit events at `path`, newest first, `perPage` at a time (what the endpoint + * returns per call; it pages with `before=`). `target` adds what each event is about. + */ +export function AuditEvents({ path, perPage, target = false }: { path: string; perPage: number; target?: boolean }) { + // the `before` cursor of each page shown so far, the first page having none + const [cursors, setCursors] = useState<(number | undefined)[]>([undefined]); + const before = cursors[cursors.length - 1]; + const q = useQuery({ + queryKey: ["audit", path, before], + queryFn: () => api.get(before == null ? path : `${path}?before=${before}`), + }); + const page = cursors.length; + const older = () => { + const last = q.data?.at(-1); + if (last) setCursors((c) => [...c, last.id]); + }; + return ( +
+ + {(events) => + events.length === 0 ? ( + {page === 1 ? "No events." : "No older events."} + ) : ( +
+ {events.map((e) => ( +
+ {e.action} + {target && ( + + {e.target_type} {e.target_id} + + )} + {JSON.stringify(e.details)} + {e.principal_name ?? e.principal_kind} + {timeAgo(e.created_at)} +
+ ))} +
+ ) + } +
+ (n < page ? setCursors((c) => c.slice(0, n)) : older())} labels={["Newer", "Older"]} /> +
+ ); +} diff --git a/web/src/pages/Admin.tsx b/web/src/pages/Admin.tsx index 6d3997c..af6753a 100644 --- a/web/src/pages/Admin.tsx +++ b/web/src/pages/Admin.tsx @@ -1,4 +1,5 @@ import { HostRules } from "../components/HostRules"; +import { AuditEvents } from "../components/AuditEvents"; import { useState, type FormEvent } from "react"; import { Route, Routes } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -48,7 +49,7 @@ export function Admin() { } /> } /> } /> - } /> + } /> ); @@ -285,27 +286,9 @@ function SettingsForm({ initial }: { initial: S["InstanceSettings"] }) { ); } -function AuditLog() { - const q = useQuery({ queryKey: ["admin-audit"], queryFn: () => api.get("/admin/audit") }); - return ( - - {(events) => ( -
- {events.map((e) => ( -
- {e.action} - - {e.target_type} {e.target_id} - - {JSON.stringify(e.details)} - {e.principal_name ?? e.principal_kind} - {timeAgo(e.created_at)} -
- ))} -
- )} -
- ); +function AdminAuditLog() { + // the instance endpoint returns 200 events a call + return ; } function DependencyUpdatesAdmin() { diff --git a/web/src/pages/project/Audit.tsx b/web/src/pages/project/Audit.tsx new file mode 100644 index 0000000..ddfec0c --- /dev/null +++ b/web/src/pages/project/Audit.tsx @@ -0,0 +1,14 @@ +import { AuditEvents } from "../../components/AuditEvents"; +import { ListHead } from "../../components/ui"; +import type { Ctx } from "./Project"; + +/** Who changed what in a project, and when (Maintainers and above). */ +export function ProjectAuditLog({ ctx }: { ctx: Ctx }) { + return ( +
+ + {/* the project endpoint returns 100 events a call */} + +
+ ); +} diff --git a/web/src/pages/project/Project.tsx b/web/src/pages/project/Project.tsx index e30dda9..19acf57 100644 --- a/web/src/pages/project/Project.tsx +++ b/web/src/pages/project/Project.tsx @@ -17,6 +17,7 @@ import { useSession } from "../../session"; import { useToast } from "../../shell/toast"; import { Members } from "../../components/Members"; import { RecentBranchBanner } from "./MrStack"; +import { settingsPage } from "./settingsPages"; // each section loads with its first visit (the repository's home needs none of these) const mrs = () => import("./MergeRequests"); @@ -40,8 +41,8 @@ const JobPage = lazy(() => import("./ci/Job").then((m) => ({ default: m.JobPage const ArtifactBrowser = lazy(() => import("./ci/Artifacts").then((m) => ({ default: m.ArtifactBrowser }))); const Schedules = lazy(() => import("./ci/Schedules").then((m) => ({ default: m.Schedules }))); const Environments = lazy(() => import("./ci/Environments").then((m) => ({ default: m.Environments }))); -const CiSettings = lazy(() => import("./ci/Settings").then((m) => ({ default: m.CiSettings }))); const ProjectSettings = lazy(() => import("./Settings").then((m) => ({ default: m.ProjectSettings }))); +const ProjectAuditLog = lazy(() => import("./Audit").then((m) => ({ default: m.ProjectAuditLog }))); const SecurityPage = lazy(() => import("./Security").then((m) => ({ default: m.SecurityPage }))); const Registry = lazy(() => import("./Registry").then((m) => ({ default: m.Registry }))); const EditFile = lazy(() => import("./Edit").then((m) => ({ default: m.EditFile }))); @@ -59,6 +60,7 @@ const SECTIONS: Record = { releases: "Releases", container_registry: "Container registry", security: "Security", + audit: "Audit log", compare: "Compare", members: "Members", merge_requests: "Merge requests", @@ -72,18 +74,27 @@ const SECTIONS: Record = { environments: "Environments", }; +/** Sections with no breadcrumb: the project's home and the file editor's actions. */ +const UNTITLED = ["", "edit", "new", "delete"]; +const knownSection = (section: string) => UNTITLED.includes(section) || Object.hasOwn(SECTIONS, section); + /** Sidebar + breadcrumbs for a project page. */ function ProjectNav({ p, rest, gitRef }: { p: S["ProjectDetail"]; rest: string; gitRef?: string }) { const [section, ...more] = rest.split("/"); const crumbs: { label: string; to?: string }[] = []; const web = `/${p.full_path}`; - if (SECTIONS[section]) crumbs.push({ label: SECTIONS[section], to: section === "commit" ? `${web}/-/commits/${gitRef ?? p.default_branch ?? "main"}` : section === "tree" || section === "blob" ? web : section === "jobs" ? `${web}/-/pipelines` : `${web}/-/${section}` }); + // the audit log is shown under Security + if (section === "audit") crumbs.push({ label: "Security", to: `${web}/-/security` }); + if (Object.hasOwn(SECTIONS, section)) crumbs.push({ label: SECTIONS[section], to: section === "commit" ? `${web}/-/commits/${gitRef ?? p.default_branch ?? "main"}` : section === "tree" || section === "blob" ? web : section === "jobs" ? `${web}/-/pipelines` : `${web}/-/${section}` }); if (section === "merge_requests" && more[0]) crumbs.push({ label: more[0] === "new" ? "New" : `!${more[0]}` }); if (section === "issues" && more[0]) crumbs.push({ label: more[0] === "new" ? "New" : `#${more[0]}` }); if (section === "commit" && more[0]) crumbs.push({ label: more[0].slice(0, 8) }); if (section === "pipelines" && more[0]) crumbs.push({ label: more[0] === "new" ? "Run" : `#${more[0]}` }); if (section === "jobs" && more[0]) crumbs.push({ label: `#${more[0]}` }); - if (section === "settings" && more[0] === "ci_cd") crumbs.push({ label: "CI/CD" }); + if (section === "settings" && more.length === 1) { + const page = settingsPage(more[0]); + if (page) crumbs.push({ label: page.label }); + } if (section === "milestones" && more[0]) crumbs.push({ label: more[0] === "new" ? "New" : more[1] === "edit" ? "Edit" : `Milestone` }); if (section === "releases" && more[0]) crumbs.push({ label: more.length === 1 && more[0] === "new" ? "New" : more.at(-1) === "edit" ? `Edit ${more.slice(0, -1).join("/")}` : more.join("/") }); useSetNav({ kind: "project", project: p, ref: gitRef, crumbs }, [p, rest, gitRef]); @@ -148,6 +159,7 @@ export function ProjectPage({ id, fullPath, rest }: { id: number; fullPath: stri {section === "releases" && } {section === "container_registry" && } {section === "security" && } + {section === "audit" && (p.can_admin && more.length === 0 ? : )} {section === "compare" && } {section === "members" && } {section === "issues" && more.length === 0 && } @@ -163,8 +175,7 @@ export function ProjectPage({ id, fullPath, rest }: { id: number; fullPath: stri {section === "merge_requests" && /^\d+$/.test(more[0] ?? "") && ( )} - {section === "settings" && more[0] !== "ci_cd" && (p.can_admin ? : )} - {section === "settings" && more[0] === "ci_cd" && (p.can_admin ? : )} + {section === "settings" && (p.can_admin && more.length <= 1 ? : )} {section === "pipelines" && more.length === 0 && } {section === "pipelines" && more[0] === "new" && } {section === "pipelines" && /^\d+$/.test(more[0] ?? "") && } @@ -172,7 +183,7 @@ export function ProjectPage({ id, fullPath, rest }: { id: number; fullPath: stri {section === "jobs" && /^\d+$/.test(more[0] ?? "") && more[1] === "artifacts" && } {section === "pipeline_schedules" && (p.can_push ? : )} {section === "environments" && } - {!["", "tree", "blob", "blame", "edit", "new", "delete", "commits", "commit", "branches", "tags", "releases", "container_registry", "security", "compare", "members", "settings", "merge_requests", "issues", "labels", "milestones", "pipelines", "jobs", "pipeline_schedules", "environments"].includes(section) && } + {!knownSection(section) && } )} diff --git a/web/src/pages/project/Settings.tsx b/web/src/pages/project/Settings.tsx index e707dd8..3d14d8e 100644 --- a/web/src/pages/project/Settings.tsx +++ b/web/src/pages/project/Settings.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState, type ChangeEvent } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { lazy, useEffect, useState, type ChangeEvent, type ComponentType } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, timeAgo, type S, bytes } from "../../api/client"; -import { Badge, Section, ErrorBox, Field, Q, VisibilitySelect, ListHead, Tile, QuotaBar, useFormRun } from "../../components/ui"; +import { Badge, Section, ErrorBox, Field, Q, VisibilitySelect, ListHead, NotFound, Tile, QuotaBar, useFormRun } from "../../components/ui"; import { AvatarUpload } from "../../components/AvatarUpload"; import { Select } from "../../components/controls"; import { AccessTokens, DeployTokens } from "../../components/Tokens"; @@ -12,7 +12,8 @@ import { HostRules } from "../../components/HostRules"; import { useBranches } from "./Code"; import { fetchers } from "../../prefetch"; import { StatusBadge } from "./ci/Status"; -import { useToast } from "../../shell/toast"; +import { settingsPage, type SettingsSlug } from "./settingsPages"; +const CiSettings = lazy(() => import("./ci/Settings").then((m) => ({ default: m.CiSettings }))); /** Who may push or merge to a protected branch, or create a protected tag. */ const PROTECT_LEVELS = [ @@ -21,7 +22,104 @@ const PROTECT_LEVELS = [ { value: "maintainer", label: "Maintainers" }, ]; -export function ProjectSettings({ ctx }: { ctx: Ctx }) { +/** Each settings page, by its slug in `settingsPages.ts` (a page missing here does not compile). */ +const PAGES: Record> = { + "": GeneralSettings, + repository: RepositorySettings, + merge_requests: MergeRequestSettings, + ci_cd: CiSettings, + automation: AutomationSettings, + integrations: IntegrationSettings, +}; + +/** A settings page of a project: `settings` (general) or `settings/`. */ +export function ProjectSettings({ ctx, page }: { ctx: Ctx; page: string }) { + const found = settingsPage(page); + if (!found) return ; + const Page = PAGES[found.slug]; + return ; +} + +/** Sections that had an anchor here before they moved to a page of their own. */ +const MOVED: Record = { "#pages": "ci_cd", "#security-scanning": "automation" }; + +function RepositorySettings({ ctx }: { ctx: Ctx }) { + const p = ctx.p; + return ( +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +} + +function MergeRequestSettings({ ctx }: { ctx: Ctx }) { + const p = ctx.p; + return ( +
+ +
+ +
+
+ ); +} + +function AutomationSettings({ ctx }: { ctx: Ctx }) { + return ( +
+ +
+ +
+
+ + +
+
+ ); +} + +function IntegrationSettings({ ctx }: { ctx: Ctx }) { + const p = ctx.p; + return ( +
+ +
+ +
+
+ +
+
+ ); +} + +function GeneralSettings({ ctx }: { ctx: Ctx }) { const p = ctx.p; const qc = useQueryClient(); const nav = useNavigate(); @@ -33,10 +131,15 @@ export function ProjectSettings({ ctx }: { ctx: Ctx }) { const [target, setTarget] = useState(""); const branches = useBranches(ctx); const { run, msg, error } = useFormRun(() => qc.invalidateQueries({ queryKey: ["project", p.id] })); + // old links to a section that moved: follow it + const { hash } = useLocation(); + useEffect(() => { + if (Object.hasOwn(MOVED, hash)) nav(`${ctx.web}/-/settings/${MOVED[hash]}${hash}`, { replace: true }); + }, [hash]); return (
- + {msg &&
{msg}
}
@@ -71,56 +174,9 @@ export function ProjectSettings({ ctx }: { ctx: Ctx }) {
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- - -
-
- -
-
- -
-
- -
-
- -
-
- -
{p.can_own && (
@@ -639,66 +695,6 @@ function StorageUse({ ctx }: { ctx: Ctx }) { ); } -function PagesSite({ ctx }: { ctx: Ctx }) { - const qc = useQueryClient(); - const toast = useToast(); - const q = useQuery({ queryKey: ["pages", ctx.p.id], queryFn: () => api.get(`/projects/${ctx.p.id}/pages`) }); - return ( - - {(s) => - !s.enabled ? ( -

- This instance does not serve sites: an administrator sets pages.domain (a wildcard DNS name) to turn them on. -

- ) : ( -
- {s.deployment ? ( - <> -

- {s.public ? ( - - {s.url} - - ) : ( - {s.url} - )} -

-

- Published from {s.deployment.publish}/ of job #{s.deployment.build_id} (pipeline #{s.deployment.pipeline_iid}),{" "} - {timeAgo(s.deployment.created_at)}. Its artifacts are kept while it is the site. - {!s.public && " Only public projects' sites are served: this one is not reachable."} -

-
- -
- - ) : ( -

- Not published yet. Once a Pages job passes, the site is at {s.url} - {!s.public && " (public projects only)"}. -

- )} -
- ) - } -
- ); -} - function ProtectedTags({ ctx }: { ctx: Ctx }) { const qc = useQueryClient(); const key = ["protected-tags", ctx.p.id]; @@ -825,27 +821,3 @@ function DeployKeys({ ctx }: { ctx: Ctx }) {
); } - -function Audit({ ctx }: { ctx: Ctx }) { - const q = useQuery({ queryKey: ["audit", ctx.p.id], queryFn: () => api.get(`/projects/${ctx.p.id}/audit`) }); - return ( - - {(events) => - events.length === 0 ? ( -

No events.

- ) : ( -
- {events.slice(0, 50).map((e) => ( -
- {e.action} - {JSON.stringify(e.details)} - {e.principal_name ?? e.principal_kind} - {timeAgo(e.created_at)} -
- ))} -
- ) - } -
- ); -} diff --git a/web/src/pages/project/ci/Settings.tsx b/web/src/pages/project/ci/Settings.tsx index 4a336a1..3d9f96d 100644 --- a/web/src/pages/project/ci/Settings.tsx +++ b/web/src/pages/project/ci/Settings.tsx @@ -1,7 +1,8 @@ import { useEffect, useState, type FormEvent } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Eye, EyeOff, KeyRound, Pencil, Plus, Trash2 } from "lucide-react"; -import { api, type S } from "../../../api/client"; +import { Link } from "react-router-dom"; +import { api, timeAgo, type S } from "../../../api/client"; import { Section, Empty, ErrorBox, Field, Q, ListHead } from "../../../components/ui"; import { RunnersPanel } from "./Runners"; import { Select } from "../../../components/controls"; @@ -299,7 +300,7 @@ function JobTokenAllowlist({ ctx }: { ctx: Ctx }) { export function CiSettings({ ctx }: { ctx: Ctx }) { return (
- + +
+ +
); @@ -385,3 +389,64 @@ function ProtectedEnvironments({ ctx }: { ctx: Ctx }) {
); } + +/** The project's static site, published by its Pages job. */ +function PagesSite({ ctx }: { ctx: Ctx }) { + const qc = useQueryClient(); + const toast = useToast(); + const q = useQuery({ queryKey: ["pages", ctx.p.id], queryFn: () => api.get(`/projects/${ctx.p.id}/pages`) }); + return ( + + {(s) => + !s.enabled ? ( +

+ This instance does not serve sites: an administrator sets pages.domain (a wildcard DNS name) to turn them on. +

+ ) : ( +
+ {s.deployment ? ( + <> +

+ {s.public ? ( + + {s.url} + + ) : ( + {s.url} + )} +

+

+ Published from {s.deployment.publish}/ of job #{s.deployment.build_id} (pipeline #{s.deployment.pipeline_iid}),{" "} + {timeAgo(s.deployment.created_at)}. Its artifacts are kept while it is the site. + {!s.public && " Only public projects' sites are served: this one is not reachable."} +

+
+ +
+ + ) : ( +

+ Not published yet. Once a Pages job passes, the site is at {s.url} + {!s.public && " (public projects only)"}. +

+ )} +
+ ) + } +
+ ); +} diff --git a/web/src/pages/project/settingsPages.ts b/web/src/pages/project/settingsPages.ts new file mode 100644 index 0000000..9908bf4 --- /dev/null +++ b/web/src/pages/project/settingsPages.ts @@ -0,0 +1,20 @@ +/** + * The pages of a project's settings, in sidebar order: `settings` (general, no slug) + * and `settings/`. The sidebar, the breadcrumbs and the routing all read this; + * `Settings.tsx` maps each slug to its page. + */ +export const SETTINGS_PAGES = [ + { slug: "", label: "General" }, + { slug: "repository", label: "Repository" }, + { slug: "merge_requests", label: "Merge requests" }, + { slug: "ci_cd", label: "CI/CD" }, + { slug: "automation", label: "Automation" }, + { slug: "integrations", label: "Integrations" }, +] as const; + +export type SettingsSlug = (typeof SETTINGS_PAGES)[number]["slug"]; + +/** The settings page at `settings/` (`""` for the general one), if there is one. */ +export function settingsPage(slug: string) { + return SETTINGS_PAGES.find((s) => s.slug === slug); +} diff --git a/web/src/shell/Shell.tsx b/web/src/shell/Shell.tsx index 95401ae..03f6043 100644 --- a/web/src/shell/Shell.tsx +++ b/web/src/shell/Shell.tsx @@ -44,6 +44,7 @@ import { HoverCards } from "../components/HoverCards"; import { Avatar, Tile } from "../components/ui"; import { ResendLink } from "../components/ResendLink"; import { useWork } from "../prefetch"; +import { SETTINGS_PAGES } from "../pages/project/settingsPages"; import { InboxBell } from "../components/Inbox"; /** A nav entry; `children` show under it while its section is active. */ @@ -94,7 +95,19 @@ function projectItems(p: S["ProjectDetail"], ref: string, mrCount?: number, issu const schedules = p.can_push ? [{ to: `${web}/-/pipeline_schedules`, label: "Schedules", match: sec("pipeline_schedules") }] : []; // findings are for Developers and above const developer = p.can_push || p.can_admin || ["developer", "maintainer", "owner"].includes(p.access_level); - const security = developer ? [{ to: `${web}/-/security`, label: "Security", icon: , match: sec("security") }] : []; + // the audit log is for Maintainers and above, with the findings + const audit = p.can_admin ? [{ to: `${web}/-/audit`, label: "Audit log", match: sec("audit") }] : []; + const security = developer + ? [ + { + to: `${web}/-/security`, + label: "Security", + icon: , + match: sec("security|audit"), + children: [{ to: `${web}/-/security`, label: "Findings", match: sec("security") }, ...audit], + }, + ] + : []; return [ { to: web, @@ -144,10 +157,11 @@ function projectItems(p: S["ProjectDetail"], ref: string, mrCount?: number, issu label: "Settings", icon: , match: sec("settings"), - children: [ - { to: `${web}/-/settings`, label: "General", match: new RegExp(`^/${esc}/-/settings/?$`) }, - { to: `${web}/-/settings/ci_cd`, label: "CI/CD", match: sec("settings/ci_cd") }, - ], + // only the page's own path: deeper ones are not found + children: SETTINGS_PAGES.map(({ slug, label }) => { + const path = slug ? `settings/${slug}` : "settings"; + return { to: `${web}/-/${path}`, label, match: new RegExp(`^/${esc}/-/${path}/?$`) }; + }), }, ] : []),