diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 992dc77837..314bc29268 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -2,6 +2,8 @@ NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}0 NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01 STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo +STACK_CHANGELOG_URL=https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md + STACK_SEED_ENABLE_DUMMY_PROJECT=true STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true diff --git a/apps/backend/src/app/api/latest/internal/changelog/route.tsx b/apps/backend/src/app/api/latest/internal/changelog/route.tsx new file mode 100644 index 0000000000..e9768863ee --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/changelog/route.tsx @@ -0,0 +1,179 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; + +const REVALIDATE_SECONDS = 60 * 60; + +type ChangeType = "major" | "minor" | "patch"; + +type ChangelogEntry = { + version: string, + type: ChangeType, + markdown: string, + bulletCount: number, + releasedAt?: string, + isUnreleased?: boolean, +}; + +type TaggedBullet = { text: string, tags: string[] }; + +function parseTaggedBullet(line: string): TaggedBullet { + let content = line.replace(/^- /, "").trim(); + const tags: string[] = []; + + while (content.startsWith("[")) { + const closingIndex = content.indexOf("]"); + if (closingIndex === -1) break; + + const tag = content.slice(1, closingIndex).trim(); + if (!tag) break; + + tags.push(tag); + content = content.slice(closingIndex + 1).trim(); + } + + return { text: content, tags }; +} + +function parseVersionHeading(raw: string): { version: string, releasedAt?: string, isUnreleased: boolean } { + const normalized = raw.trim(); + const isUnreleased = normalized.toLowerCase() === "unreleased"; + + if (isUnreleased) { + return { version: "Unreleased", isUnreleased: true }; + } + + const datePattern = /^(\d+\.\d+\.\d+)\s*(?:\(|-)\s*(\d{4}-\d{2}-\d{2})\)?$/; + const match = normalized.match(datePattern); + + if (match) { + return { + version: match[1], + releasedAt: match[2], + isUnreleased: false, + }; + } + + return { + version: normalized, + isUnreleased: false, + }; +} + +function parseRootChangelog(markdown: string): ChangelogEntry[] { + const entries: ChangelogEntry[] = []; + const sections = markdown.split(/(?=^## .+)/m); + + for (const section of sections) { + if (!section.trim()) continue; + + const versionMatch = section.match(/^## (.+)/m); + if (!versionMatch) continue; + + const heading = versionMatch[1].trim(); + const { version, releasedAt, isUnreleased } = parseVersionHeading(heading); + const isSemver = /^\d+\.\d+\.\d+$/.test(version); + const isCalVer = /^\d{4}\.\d{2}\.\d{2}$/.test(version); + + if (!isUnreleased && !isSemver && !isCalVer) { + continue; + } + + const versionContent = section.replace(/^## .+$/m, "").trim(); + + let type: ChangeType = "patch"; + if (versionContent.includes("### Major Changes")) type = "major"; + else if (versionContent.includes("### Minor Changes")) type = "minor"; + + const lines = versionContent.split("\n"); + const processedLines: string[] = []; + + for (const line of lines) { + if (line.trim().startsWith("- ")) { + const { text } = parseTaggedBullet(line); + processedLines.push(text ? `- ${text}` : "-"); + } else { + processedLines.push(line); + } + } + + const normalizedMarkdown = processedLines.join("\n").trim(); + const bulletCount = processedLines.filter(l => l.trim().startsWith("-")).length; + + entries.push({ + version, + type, + markdown: normalizedMarkdown, + bulletCount, + isUnreleased, + releasedAt, + }); + } + + return entries; +} + +const changelogEntrySchema = yupObject({ + version: yupString().defined(), + type: yupString().oneOf(["major", "minor", "patch"]).defined(), + markdown: yupString().defined(), + bulletCount: yupNumber().defined(), + releasedAt: yupString().optional(), + isUnreleased: yupBoolean().optional(), +}).defined(); + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200, 502]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + entries: yupArray(changelogEntrySchema).optional(), + error: yupString().optional(), + }).defined(), + }), + handler: async () => { + const changelogUrl = getEnvVariable("STACK_CHANGELOG_URL", ""); + + if (!changelogUrl) { + return { + statusCode: 200, + bodyType: "json", + body: { entries: [] }, + } as const; + } + + const response = await fetch(changelogUrl, { + headers: { + "Accept": "text/plain", + "User-Agent": "stack-auth-backend-changelog", + }, + next: { + revalidate: REVALIDATE_SECONDS, + }, + }); + + if (!response.ok) { + return { + statusCode: 502, + bodyType: "json", + body: { error: "Failed to download changelog" }, + } as const; + } + + const content = await response.text(); + const entries = parseRootChangelog(content).slice(0, 8); + + return { + statusCode: 200, + bodyType: "json", + body: { entries }, + } as const; + }, +}); + diff --git a/apps/dashboard/.env b/apps/dashboard/.env index bcb0c3bb95..5381d899f8 100644 --- a/apps/dashboard/.env +++ b/apps/dashboard/.env @@ -16,3 +16,4 @@ STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translati NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]' NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development) STACK_FEATUREBASE_JWT_SECRET=# used for Featurebase SSO, you probably won't have to set this +STACK_CHANGELOG_URL=# Used for raw github link to root changelog.md file. diff --git a/apps/dashboard/src/components/stack-companion.tsx b/apps/dashboard/src/components/stack-companion.tsx index 79d0cb0068..d0ed8bd94a 100644 --- a/apps/dashboard/src/components/stack-companion.tsx +++ b/apps/dashboard/src/components/stack-companion.tsx @@ -1,9 +1,12 @@ 'use client'; import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; +import { ChangelogEntry } from '@/lib/changelog'; +import { getPublicEnvVar } from '@/lib/env'; import { cn } from '@/lib/utils'; import { checkVersion, VersionCheckResult } from '@/lib/version-check'; import { BookOpenIcon, CircleNotchIcon, ClockClockwiseIcon, LightbulbIcon, XIcon } from '@phosphor-icons/react'; +import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises'; import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import packageJson from '../../package.json'; import { FeedbackForm } from './feedback-form'; @@ -11,6 +14,38 @@ import { ChangelogWidget } from './stack-companion/changelog-widget'; import { FeatureRequestBoard } from './stack-companion/feature-request-board'; import { UnifiedDocsWidget } from './stack-companion/unified-docs-widget'; +/** + * Compare two CalVer versions in YYYY.MM.DD format + * Returns true if version1 is newer than version2 + */ +function isNewerCalVer(version1: string, version2: string): boolean { + const parseCalVer = (version: string): Date | null => { + const match = version.match(/^(\d{4})\.(\d{2})\.(\d{2})$/); + if (!match) return null; + const [, year, month, day] = match; + return new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); + }; + + const date1 = parseCalVer(version1); + const date2 = parseCalVer(version2); + + if (!date1 || !date2) { + // Fallback to string comparison if parsing fails + return version1 > version2; + } + + return date1.getTime() > date2.getTime(); +} + +/** + * Sanitize a string value for use in a cookie + * Removes or encodes characters that could break cookie parsing + */ +function sanitizeCookieValue(value: string): string { + // Remove or encode special characters that break cookie parsing + return encodeURIComponent(value); +} + type SidebarItem = { id: string, label: string, @@ -73,6 +108,7 @@ export function useStackCompanion() { return useContext(StackCompanionContext); } + export function StackCompanion({ className }: { className?: string }) { const [activeItem, setActiveItem] = useState(null); const [mounted, setMounted] = useState(false); @@ -82,6 +118,9 @@ export function StackCompanion({ className }: { className?: string }) { const [isAnimating, setIsAnimating] = useState(false); const [isDragging, setIsDragging] = useState(false); const [isSplitScreenMode, setIsSplitScreenMode] = useState(false); + const [changelogData, setChangelogData] = useState(undefined); + const [hasNewVersions, setHasNewVersions] = useState(false); + const [lastSeenVersion, setLastSeenVersion] = useState(''); const startXRef = useRef(0); const startWidthRef = useRef(0); @@ -125,6 +164,84 @@ export function StackCompanion({ className }: { className?: string }) { return cleanup; }, []); + // Fetch changelog data on mount and check for new versions + useEffect(() => { + runAsynchronously(async () => { + const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || ''; + const response = await fetch(`${baseUrl}/api/latest/internal/changelog`); + if (!response.ok) { + return; + } + + const payload = await response.json(); + const entries = payload.entries || []; + setChangelogData(entries); + + // Check for new versions + const lastSeenRaw = document.cookie + .split('; ') + .find(row => row.startsWith('stack-last-seen-changelog-version=')) + ?.split('=')[1] || ''; + + const lastSeen = lastSeenRaw ? decodeURIComponent(lastSeenRaw) : ''; + setLastSeenVersion(lastSeen); + + if (entries.length > 0) { + // If no lastSeen cookie, user hasn't seen any changelog yet - show bell + if (!lastSeen) { + setHasNewVersions(true); + } else { + const hasNewer = entries.some((entry: ChangelogEntry) => { + if (entry.isUnreleased) return false; + return isNewerCalVer(entry.version, lastSeen); + }); + setHasNewVersions(hasNewer); + } + } + }); + }, []); + + // Re-check for new versions when changelog is opened/closed + useEffect(() => { + if (activeItem === 'changelog') { + // When changelog is opened, mark the latest released version as seen + // Skip unreleased versions to avoid breaking version comparison + if (changelogData && changelogData.length > 0) { + const latestReleasedEntry = changelogData.find(entry => !entry.isUnreleased); + if (latestReleasedEntry) { + document.cookie = `stack-last-seen-changelog-version=${sanitizeCookieValue(latestReleasedEntry.version)}; path=/; max-age=31536000`; // 1 year + setLastSeenVersion(latestReleasedEntry.version); + } + } + // Clear the notification badge immediately + setHasNewVersions(false); + } else if (activeItem === null) { + // When closed, re-check if there are new versions + const lastSeenRaw = document.cookie + .split('; ') + .find(row => row.startsWith('stack-last-seen-changelog-version=')) + ?.split('=')[1] || ''; + + const lastSeen = lastSeenRaw ? decodeURIComponent(lastSeenRaw) : ''; + + if (changelogData && changelogData.length > 0) { + // If no lastSeen cookie, user hasn't seen any changelog yet - show bell + if (!lastSeen) { + setHasNewVersions(true); + } else { + const hasNewer = changelogData.some((entry: ChangelogEntry) => { + if (entry.isUnreleased) return false; + return isNewerCalVer(entry.version, lastSeen); + }); + setHasNewVersions(hasNewer); + } + } else { + setHasNewVersions(false); + } + } + }, [activeItem, changelogData]); + + const openDrawer = useCallback((itemId: string) => { setActiveItem(itemId); setIsAnimating(true); @@ -304,7 +421,7 @@ export function StackCompanion({ className }: { className?: string }) {
{activeItem === 'docs' && } {activeItem === 'feedback' && } - {activeItem === 'changelog' && } + {activeItem === 'changelog' && } {activeItem === 'support' && }
@@ -338,7 +455,9 @@ export function StackCompanion({ className }: { className?: string }) { className={cn( "h-10 w-10 p-0 text-muted-foreground transition-all duration-[50ms] rounded-xl relative group", item.hoverBg, - activeItem === item.id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5" + activeItem === item.id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5", + // Glow effect for changelog with new updates + item.id === 'changelog' && hasNewVersions && "ring-2 ring-green-500/30 bg-green-500/10" )} onClick={(e) => { e.stopPropagation(); @@ -346,10 +465,16 @@ export function StackCompanion({ className }: { className?: string }) { }} > + {item.id === 'changelog' && hasNewVersions && ( + + + + + )} - {item.label} + {item.id === 'changelog' && hasNewVersions ? `${item.label} (New updates available!)` : item.label} ))} diff --git a/apps/dashboard/src/components/stack-companion/changelog-widget.tsx b/apps/dashboard/src/components/stack-companion/changelog-widget.tsx index 36bdec1efb..78bd0c0e22 100644 --- a/apps/dashboard/src/components/stack-companion/changelog-widget.tsx +++ b/apps/dashboard/src/components/stack-companion/changelog-widget.tsx @@ -1,285 +1,261 @@ 'use client'; import { Button } from '@/components/ui'; -import { CalendarIcon, CaretDownIcon, CaretUpIcon } from '@phosphor-icons/react'; +import { getPublicEnvVar } from '@/lib/env'; +import { CalendarIcon, CaretDownIcon, CaretUpIcon, InfoIcon } from '@phosphor-icons/react'; +import { captureError } from '@stackframe/stack-shared/dist/utils/errors'; import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises'; import Image from 'next/image'; -import Script from 'next/script'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; -type ChangelogWidgetProps = { - isActive: boolean, +type ChangeType = 'major' | 'minor' | 'patch'; + +type ApiChangelogEntry = { + version: string, + type: ChangeType, + markdown: string, + bulletCount: number, + releasedAt?: string, + isUnreleased?: boolean, }; -type ChangelogItem = { +type ChangelogItem = ApiChangelogEntry & { id: string, - title: string, - content: string, - date: string, - featuredImage?: string, - isNew?: boolean, - expanded?: boolean, + expanded: boolean, }; -export function ChangelogWidget({ isActive }: ChangelogWidgetProps) { - const [changelogs, setChangelogs] = useState([]); - const [loading, setLoading] = useState(true); +type ChangelogWidgetProps = { + isActive: boolean, + initialData?: ApiChangelogEntry[], +}; - const toggleExpanded = (id: string) => { - setChangelogs(prev => prev.map(changelog => - changelog.id === id - ? { ...changelog, expanded: !changelog.expanded } - : changelog - )); - }; +function toChangelogItems(entries: ApiChangelogEntry[]): ChangelogItem[] { + return entries.map((entry, index) => ({ + ...entry, + id: `${entry.version}-${entry.releasedAt ?? 'unreleased'}`, + expanded: index === 0, + })); +} - // Helper function to determine if content should be collapsible - const shouldCollapseContent = (content: string) => { - const textContent = content.replace(/<[^>]*>/g, ''); - return textContent.length > 200; // Collapse if text is longer than 200 characters - }; +function markLatestVersionSeen(entries: ApiChangelogEntry[]) { + // Find the first released version (skip unreleased to avoid breaking version comparison) + const latestReleasedEntry = entries.find(entry => !entry.isUnreleased); + if (latestReleasedEntry) { + document.cookie = `stack-last-seen-changelog-version=${encodeURIComponent(latestReleasedEntry.version)}; path=/; max-age=31536000`; + } +} - useEffect(() => { - if (!isActive) return; - const win = window as any; - if (typeof win.Featurebase !== "function") { - win.Featurebase = function () { - // eslint-disable-next-line prefer-rest-params - (win.Featurebase.q = win.Featurebase.q || []).push(arguments); - }; - } +const formatVersion = (version: string) => { + // Convert YYYY.MM.DD to YY.MM.DD format for display + const calVerMatch = version.match(/^(\d{4})\.(\d{2})\.(\d{2})$/); + if (calVerMatch) { + const [, year, month, day] = calVerMatch; + const shortYear = year.slice(-2); // Get last 2 digits + return `${shortYear}.${month}.${day}`; + } + return version; +}; + +// Markdown component overrides for changelog rendering +const markdownComponents = { + h3: ({ children }: { children?: React.ReactNode }) => ( +

+ {children} +

+ ), + p: ({ children }: { children?: React.ReactNode }) => ( +

+ {children} +

+ ), + ul: ({ children }: { children?: React.ReactNode }) => ( +
    + {children} +
+ ), + li: ({ children }: { children?: React.ReactNode }) => ( +
  • + {children} +
  • + ), + code: ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + blockquote: ({ children }: { children?: React.ReactNode }) => ( +
    +
    + +
    + {children} +
    +
    +
    + ), + img: ({ src, alt, title }: { src?: string, alt?: string, title?: string }) => { + if (!src) return null; + return ( + {alt + ); + }, +}; + +export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps) { + const [changelog, setChangelog] = useState([]); + const [loading, setLoading] = useState(!initialData); + const [error, setError] = useState(null); + const hasFetchedRef = useRef(false); - // Initialize the widget but disable popup since we're showing inline - win.Featurebase("init_changelog_widget", { - organization: "stackauth", - dropdown: { - enabled: false, // Disable since we're showing inline - }, - popup: { - enabled: false, // Disable popup since we're showing inline - autoOpenForNewUpdates: false, - }, - theme: "light", - locale: "en", - }); + const fetchChangelog = useCallback(async (signal?: AbortSignal) => { + try { + setLoading(true); + setError(null); - // Fetch changelog data directly from Featurebase API - const fetchChangelogs = async () => { - try { - setLoading(true); - const response = await fetch('https://stackauth.featurebase.app/api/v1/changelog', { - headers: { - 'Accept': 'application/json', - }, - }); + const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || ''; + const response = await fetch(`${baseUrl}/api/latest/internal/changelog`, { signal }); - if (response.ok) { - const data = await response.json(); - // Transform the data to our format - API returns { results: [...] } - const transformedData = data.results?.slice(0, 10).map((item: any) => ({ - id: item.id, - title: item.title, - content: item.content || 'No content available', - date: new Date(item.date).toLocaleDateString(), - featuredImage: item.featuredImage, - isNew: new Date(item.date) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Consider new if within last 7 days - expanded: false, // Start collapsed - })) || []; - setChangelogs(transformedData); - } else { - console.error('Failed to fetch changelogs:', response.status, response.statusText); - setChangelogs([]); - } - } catch (error) { - console.error('Failed to fetch changelogs:', error); - setChangelogs([]); - } finally { + if (!response.ok) { + throw new Error(`Failed to fetch changelog: ${response.status}`); + } + + const payload = await response.json(); + const entries: ApiChangelogEntry[] = payload.entries || []; + + setChangelog(toChangelogItems(entries)); + markLatestVersionSeen(entries); + } catch (cause) { + if (signal?.aborted) { + return; + } + captureError('changelog-fetching', cause); + setError('Unable to load the changelog right now.'); + setChangelog([]); + } finally { + if (!signal?.aborted) { setLoading(false); } - }; + } + }, []); + + useEffect(() => { + if (!isActive || hasFetchedRef.current) { + return; + } + + hasFetchedRef.current = true; + + if (initialData !== undefined) { + setChangelog(toChangelogItems(initialData)); + setLoading(false); + markLatestVersionSeen(initialData); + } else { + const abortController = new AbortController(); + runAsynchronously(fetchChangelog(abortController.signal)); + return () => abortController.abort(); + } + }, [fetchChangelog, isActive, initialData]); - runAsynchronously(fetchChangelogs()); - }, [isActive]); + const toggleExpanded = (id: string) => { + setChangelog((prev) => prev.map((entry) => + entry.id === id ? { ...entry, expanded: !entry.expanded } : entry, + )); + }; if (loading) { return ( - <> -