diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 304d934af1..3f1916af71 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.25.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Sign-in and sign-up redesigned with an animated background and a live release highlights panel from GitHub, with adaptive summaries for patch releases [(#10774)](https://github.com/prowler-cloud/prowler/pull/10774) + +--- + ## [1.24.1] (Prowler v5.24.1) ### 🔒 Security diff --git a/ui/actions/releases/index.ts b/ui/actions/releases/index.ts new file mode 100644 index 0000000000..6987a99955 --- /dev/null +++ b/ui/actions/releases/index.ts @@ -0,0 +1,2 @@ +export * from "./releases"; +export * from "./types"; diff --git a/ui/actions/releases/releases.ts b/ui/actions/releases/releases.ts new file mode 100644 index 0000000000..f72b1b60de --- /dev/null +++ b/ui/actions/releases/releases.ts @@ -0,0 +1,125 @@ +"use server"; + +import { unstable_cache } from "next/cache"; +import { z } from "zod"; + +import type { LatestRelease, ReleaseComponent } from "./types"; +import { RELEASE_COMPONENTS } from "./types"; + +const REPO = "prowler-cloud/prowler"; +const REPO_URL = `https://github.com/${REPO}`; +const RELEASES_ENDPOINT = `https://api.github.com/repos/${REPO}/releases?per_page=10`; +const MAX_HIGHLIGHTS = 3; +const REVALIDATE_SECONDS = 3600; + +const META_HEADINGS = [ + /^what'?s changed$/i, + /^new contributors$/i, + /^full changelog$/i, +]; + +const releaseSchema = z.object({ + tag_name: z.string(), + html_url: z.url(), + body: z.string().nullable(), + draft: z.boolean(), + prerelease: z.boolean(), +}); + +const releaseListSchema = z.array(releaseSchema); + +const stripLeadingSymbols = (s: string) => + s.replace(/^[^A-Za-z0-9]+/, "").trim(); + +const startsWithNonAscii = (s: string) => { + const first = s.trim().charCodeAt(0); + return Number.isFinite(first) && first > 127; +}; + +const stripFencedAndDetails = (body: string) => + body + .replace(/```[\s\S]*?```/g, "") + .replace(//gi, ""); + +const parseBody = ( + body: string, +): { curated: string[]; components: ReleaseComponent[] } => { + const clean = stripFencedAndDetails(body); + const headings = Array.from(clean.matchAll(/^##\s+(.+?)\s*$/gm)).map((m) => + m[1].trim(), + ); + + const curated = headings + .filter(startsWithNonAscii) + .filter((h) => !META_HEADINGS.some((re) => re.test(stripLeadingSymbols(h)))) + .slice(0, MAX_HIGHLIGHTS); + + const components: ReleaseComponent[] = RELEASE_COMPONENTS.filter((comp) => + headings.some((h) => h.toUpperCase() === comp), + ); + + return { curated, components }; +}; + +const buildHeaders = (): HeadersInit => { + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (process.env.GITHUB_TOKEN) { + headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; + } + return headers; +}; + +const fetchLatestReleaseWithHighlights = async (): Promise => { + const res = await fetch(RELEASES_ENDPOINT, { + headers: buildHeaders(), + next: { revalidate: REVALIDATE_SECONDS }, + }); + + if (!res.ok) { + throw new Error(`GitHub releases API responded with ${res.status}`); + } + + const json = await res.json(); + const releases = releaseListSchema.parse(json); + + for (const release of releases) { + if (release.draft || release.prerelease || !release.body) continue; + const { curated, components } = parseBody(release.body); + + const base = { + version: release.tag_name.replace(/^v/, ""), + url: release.html_url, + repoUrl: REPO_URL, + }; + + if (curated.length > 0) { + return { ...base, kind: "curated", highlights: curated }; + } + if (components.length > 0) { + return { ...base, kind: "patch", components }; + } + } + + throw new Error("No releases with parseable highlights or components"); +}; + +const getCachedLatestRelease = unstable_cache( + fetchLatestReleaseWithHighlights, + ["github-latest-release", REPO], + { + revalidate: REVALIDATE_SECONDS, + tags: ["releases"], + }, +); + +export async function getLatestRelease(): Promise { + try { + return await getCachedLatestRelease(); + } catch (err) { + console.error("[releases] failed to resolve latest release:", err); + return null; + } +} diff --git a/ui/actions/releases/types.ts b/ui/actions/releases/types.ts new file mode 100644 index 0000000000..fd94844b44 --- /dev/null +++ b/ui/actions/releases/types.ts @@ -0,0 +1,21 @@ +export const RELEASE_COMPONENTS = ["UI", "API", "SDK"] as const; + +export type ReleaseComponent = (typeof RELEASE_COMPONENTS)[number]; + +interface LatestReleaseBase { + version: string; + url: string; + repoUrl: string; +} + +export interface CuratedRelease extends LatestReleaseBase { + kind: "curated"; + highlights: string[]; +} + +export interface PatchRelease extends LatestReleaseBase { + kind: "patch"; + components: ReleaseComponent[]; +} + +export type LatestRelease = CuratedRelease | PatchRelease; diff --git a/ui/app/(auth)/(guest-only)/sign-in/page.tsx b/ui/app/(auth)/(guest-only)/sign-in/page.tsx index c36226e61f..2b2cee0615 100644 --- a/ui/app/(auth)/(guest-only)/sign-in/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-in/page.tsx @@ -1,4 +1,8 @@ +import { Suspense } from "react"; + import { AuthForm } from "@/components/auth/oss"; +import { AuthReleaseHighlightsServer } from "@/components/auth/oss/auth-release-highlights-server"; +import { AuthReleaseHighlightsSkeleton } from "@/components/auth/oss/auth-release-highlights-skeleton"; import { getAuthUrl, isGithubOAuthEnabled, @@ -15,6 +19,14 @@ const SignIn = () => { githubAuthUrl={GITHUB_AUTH_URL} isGoogleOAuthEnabled={isGoogleOAuthEnabled} isGithubOAuthEnabled={isGithubOAuthEnabled} + releaseHighlights={ + } + > + + + } /> ); }; diff --git a/ui/app/(auth)/(guest-only)/sign-up/page.tsx b/ui/app/(auth)/(guest-only)/sign-up/page.tsx index b846c59a93..6f4801bb34 100644 --- a/ui/app/(auth)/(guest-only)/sign-up/page.tsx +++ b/ui/app/(auth)/(guest-only)/sign-up/page.tsx @@ -1,6 +1,13 @@ +import { Suspense } from "react"; + import { AuthForm } from "@/components/auth/oss"; -import { getAuthUrl, isGithubOAuthEnabled } from "@/lib/helper"; -import { isGoogleOAuthEnabled } from "@/lib/helper"; +import { AuthReleaseHighlightsServer } from "@/components/auth/oss/auth-release-highlights-server"; +import { AuthReleaseHighlightsSkeleton } from "@/components/auth/oss/auth-release-highlights-skeleton"; +import { + getAuthUrl, + isGithubOAuthEnabled, + isGoogleOAuthEnabled, +} from "@/lib/helper"; import { SearchParamsProps } from "@/types"; const SignUp = async ({ @@ -25,6 +32,14 @@ const SignUp = async ({ githubAuthUrl={GITHUB_AUTH_URL} isGoogleOAuthEnabled={isGoogleOAuthEnabled} isGithubOAuthEnabled={isGithubOAuthEnabled} + releaseHighlights={ + } + > + + + } /> ); }; diff --git a/ui/components/auth/oss/animated-dots-background.tsx b/ui/components/auth/oss/animated-dots-background.tsx index 6eaf29d88d..92f4ae965a 100644 --- a/ui/components/auth/oss/animated-dots-background.tsx +++ b/ui/components/auth/oss/animated-dots-background.tsx @@ -10,9 +10,9 @@ const PARTICLE_SPEED = 0.35; const CONNECTION_DISTANCE = 140; const CONNECTION_DISTANCE_SQ = CONNECTION_DISTANCE * CONNECTION_DISTANCE; const LINE_WIDTH = 0.8; -const MOUSE_RADIUS = 160; +const MOUSE_RADIUS = 100; const MOUSE_RADIUS_SQ = MOUSE_RADIUS * MOUSE_RADIUS; -const MOUSE_FORCE = 2.2; +const MOUSE_FORCE = 1.4; const PARTICLE_ALPHA = 0.75; const CONNECTION_MAX_ALPHA = 0.35; const ALPHA_TIER_COUNT = 3; diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index 595aea4e18..81a36e9db7 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -1,3 +1,5 @@ +import { ReactNode } from "react"; + import { SignInForm } from "@/components/auth/oss/sign-in-form"; import { SignUpForm } from "@/components/auth/oss/sign-up-form"; @@ -8,6 +10,7 @@ export const AuthForm = ({ githubAuthUrl, isGoogleOAuthEnabled, isGithubOAuthEnabled, + releaseHighlights, }: { type: string; invitationToken?: string | null; @@ -15,6 +18,7 @@ export const AuthForm = ({ githubAuthUrl?: string; isGoogleOAuthEnabled?: boolean; isGithubOAuthEnabled?: boolean; + releaseHighlights?: ReactNode; }) => { if (type === "sign-in") { return ( @@ -23,6 +27,7 @@ export const AuthForm = ({ githubAuthUrl={githubAuthUrl} isGoogleOAuthEnabled={isGoogleOAuthEnabled} isGithubOAuthEnabled={isGithubOAuthEnabled} + releaseHighlights={releaseHighlights} /> ); } @@ -34,6 +39,7 @@ export const AuthForm = ({ githubAuthUrl={githubAuthUrl} isGoogleOAuthEnabled={isGoogleOAuthEnabled} isGithubOAuthEnabled={isGithubOAuthEnabled} + releaseHighlights={releaseHighlights} /> ); }; diff --git a/ui/components/auth/oss/auth-layout.tsx b/ui/components/auth/oss/auth-layout.tsx index 5b8df39c79..7d8f24d6aa 100644 --- a/ui/components/auth/oss/auth-layout.tsx +++ b/ui/components/auth/oss/auth-layout.tsx @@ -2,24 +2,28 @@ import { ReactNode } from "react"; import { AnimatedDotsBackground } from "@/components/auth/oss/animated-dots-background"; import { AuthCard } from "@/components/auth/oss/auth-card"; -import { AuthReleaseHighlights } from "@/components/auth/oss/auth-release-highlights"; import { ProwlerExtended } from "@/components/icons"; import { ThemeSwitch } from "@/components/ThemeSwitch"; interface AuthLayoutProps { title: string; children: ReactNode; + releaseHighlights?: ReactNode; } -export const AuthLayout = ({ title, children }: AuthLayoutProps) => ( +export const AuthLayout = ({ + title, + children, + releaseHighlights, +}: AuthLayoutProps) => (
-
+
- +
@@ -32,7 +36,7 @@ export const AuthLayout = ({ title, children }: AuthLayoutProps) => (
- + {releaseHighlights}
diff --git a/ui/components/auth/oss/auth-release-highlights-server.tsx b/ui/components/auth/oss/auth-release-highlights-server.tsx new file mode 100644 index 0000000000..d185bbb023 --- /dev/null +++ b/ui/components/auth/oss/auth-release-highlights-server.tsx @@ -0,0 +1,8 @@ +import { getLatestRelease } from "@/actions/releases/releases"; +import { AuthReleaseHighlights } from "@/components/auth/oss/auth-release-highlights"; + +export const AuthReleaseHighlightsServer = async () => { + const release = await getLatestRelease(); + if (!release) return null; + return ; +}; diff --git a/ui/components/auth/oss/auth-release-highlights-skeleton.tsx b/ui/components/auth/oss/auth-release-highlights-skeleton.tsx new file mode 100644 index 0000000000..ce5e13b1ba --- /dev/null +++ b/ui/components/auth/oss/auth-release-highlights-skeleton.tsx @@ -0,0 +1,5 @@ +export const AuthReleaseHighlightsSkeleton = () => ( + +); diff --git a/ui/components/auth/oss/auth-release-highlights.tsx b/ui/components/auth/oss/auth-release-highlights.tsx index 6d7676abea..16fa50da38 100644 --- a/ui/components/auth/oss/auth-release-highlights.tsx +++ b/ui/components/auth/oss/auth-release-highlights.tsx @@ -1,26 +1,35 @@ import { Icon } from "@iconify/react"; +import type { LatestRelease, ReleaseComponent } from "@/actions/releases/types"; import { ProwlerShort } from "@/components/icons"; import { Button } from "@/components/shadcn"; -const RELEASE = { - version: "5.24.0", - url: "https://github.com/prowler-cloud/prowler/releases/tag/5.24.0", - repoUrl: "https://github.com/prowler-cloud/prowler", -} as const; +interface AuthReleaseHighlightsProps { + release: LatestRelease; +} -const HIGHLIGHTS: readonly string[] = [ - "Redesigned resources side drawer", - "New AWS Bedrock & IAM hardening checks", - "New Microsoft 365 Conditional Access coverage", -]; +const formatPatchMessage = (components: ReleaseComponent[]): string => { + const [a, b, c] = components; + if (!a) return ""; + if (!b) return `This version contains multiple fixes on the ${a} component.`; + if (!c) { + return `This version contains multiple fixes across ${a} and ${b} components.`; + } + return `This version contains multiple fixes across ${a}, ${b} and ${c} components.`; +}; -export const AuthReleaseHighlights = () => ( +export const AuthReleaseHighlights = ({ + release, +}: AuthReleaseHighlightsProps) => (