Merge remote-tracking branch 'origin/dev' into external-db-sync

This commit is contained in:
Bilal Godil 2026-02-02 10:57:38 -08:00
commit b5781a146d
11 changed files with 242 additions and 159 deletions

View File

@ -2,28 +2,32 @@
---
## 2026.01.23
## 1/23/26
### Payments
Introduced a redesigned payments onboarding flow
![Payments Onboarding](https://raw.githubusercontent.com/stack-auth/stack-auth/dev/apps/dashboard/public/changelog/payments-onboarding.png)
## 2026.01.21
## 1/21/26
### Payments
- Payments page updated with new UI changes
![Create Product](https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/apps/dashboard/public/changelog/payments-create-product.png)
- Added a new Payments Settings page with an option to temporarily disable all payments
![Payments Setting](https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/apps/dashboard/public/changelog/payments-settings-1.png)
- Subscription renewal emails are now sent automatically to users
- Past payment invoices are now visible on the Account Settings page
![Past Payments Invoices](https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/apps/dashboard/public/changelog/account-settings-invoices.png)
### Documentation
- Updated JWT documentation to include `isRestricted` and `restrictedReason`
## 2026.01.19
## 1/19/26
- Updated package dependencies to their newest versions.
## 2025.12.19
## 12/19/25
- Introduces new changelog and deprecates all older changelogs.
- Moved away from semantic versioning in favor of CalVer.
- Date versioning for public view.
---

View File

@ -1,6 +1,8 @@
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";
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import * as fs from "fs/promises";
import * as path from "path";
const REVALIDATE_SECONDS = 60 * 60;
@ -72,10 +74,9 @@ function parseRootChangelog(markdown: string): ChangelogEntry[] {
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);
const isUsDate = /^\d{1,2}\/\d{1,2}\/\d{2}$/.test(version); // US date format: M/D/YY
if (!isUnreleased && !isSemver && !isCalVer) {
if (!isUnreleased && !isUsDate) {
continue;
}
@ -138,6 +139,25 @@ export const GET = createSmartRouteHandler({
}).defined(),
}),
handler: async () => {
const isDevelopment = getNodeEnvironment() === "development";
// In development mode, read from local CHANGELOG.md file
if (isDevelopment) {
const changelogPath = path.resolve(process.cwd(), "../../CHANGELOG.md");
const fileExists = await fs.access(changelogPath).then(() => true, () => false);
if (fileExists) {
const content = await fs.readFile(changelogPath, "utf-8");
const entries = parseRootChangelog(content).slice(0, 8);
return {
statusCode: 200,
bodyType: "json",
body: { entries },
} as const;
}
}
const changelogUrl = getEnvVariable("STACK_CHANGELOG_URL", "");
if (!changelogUrl) {

View File

@ -64,6 +64,12 @@ const nextConfig = {
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'raw.githubusercontent.com',
port: '',
pathname: '/**',
},
],
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@ -2,12 +2,12 @@
import { Alert, Button, Skeleton, Typography } from "@/components/ui";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -16,13 +16,13 @@ import { Switch } from "@/components/ui/switch";
import { useFromNow } from "@/hooks/use-from-now";
import { cn } from "@/lib/utils";
import {
ArrowClockwiseIcon,
ArrowDownIcon,
ArrowUpIcon,
CalendarIcon,
ClockIcon,
MagnifyingGlassIcon,
SparkleIcon,
ArrowClockwiseIcon,
ArrowDownIcon,
ArrowUpIcon,
CalendarIcon,
ClockIcon,
MagnifyingGlassIcon,
SparkleIcon,
} from "@phosphor-icons/react";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useVirtualizer } from "@tanstack/react-virtual";

View File

@ -2,11 +2,11 @@
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
@ -14,11 +14,11 @@ import { useDebouncedAction } from "@/hooks/use-debounced-action";
import { useFromNow } from "@/hooks/use-from-now";
import { cn } from "@/lib/utils";
import {
ArrowClockwiseIcon,
CheckCircleIcon,
PlayIcon,
SpinnerGapIcon,
WarningCircleIcon,
ArrowClockwiseIcon,
CheckCircleIcon,
PlayIcon,
SpinnerGapIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { memo, useCallback, useMemo, useRef, useState } from "react";

View File

@ -15,19 +15,22 @@ 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
* Compare two US date versions in M/D/YY 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})$/);
function isNewerVersion(version1: string, version2: string): boolean {
const parseUsDate = (version: string): Date | null => {
const match = version.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2})$/);
if (!match) return null;
const [, year, month, day] = match;
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
const [, month, day, year] = match;
const twoDigitYear = parseInt(year);
// Sliding window: 70-99 → 1970-1999, 00-69 → 2000-2069
const fullYear = twoDigitYear >= 70 ? 1900 + twoDigitYear : 2000 + twoDigitYear;
return new Date(fullYear, parseInt(month) - 1, parseInt(day));
};
const date1 = parseCalVer(version1);
const date2 = parseCalVer(version2);
const date1 = parseUsDate(version1);
const date2 = parseUsDate(version2);
if (!date1 || !date2) {
// Fallback to string comparison if parsing fails
@ -193,7 +196,7 @@ export function StackCompanion({ className }: { className?: string }) {
} else {
const hasNewer = entries.some((entry: ChangelogEntry) => {
if (entry.isUnreleased) return false;
return isNewerCalVer(entry.version, lastSeen);
return isNewerVersion(entry.version, lastSeen);
});
setHasNewVersions(hasNewer);
}
@ -231,7 +234,7 @@ export function StackCompanion({ className }: { className?: string }) {
} else {
const hasNewer = changelogData.some((entry: ChangelogEntry) => {
if (entry.isUnreleased) return false;
return isNewerCalVer(entry.version, lastSeen);
return isNewerVersion(entry.version, lastSeen);
});
setHasNewVersions(hasNewer);
}

View File

@ -2,11 +2,12 @@
import { Button } from '@/components/ui';
import { getPublicEnvVar } from '@/lib/env';
import { CalendarIcon, CaretDownIcon, CaretUpIcon, InfoIcon } from '@phosphor-icons/react';
import { CalendarIcon, CaretDownIcon, CaretUpIcon, InfoIcon, XIcon } 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 { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@ -49,73 +50,73 @@ function markLatestVersionSeen(entries: ApiChangelogEntry[]) {
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}`;
}
// Version is already in US date format (M/D/YY), return as-is
return version;
};
// Markdown component overrides for changelog rendering
const markdownComponents = {
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-sm font-semibold text-foreground mt-4 mb-2 first:mt-0">
{children}
</h3>
),
p: ({ children }: { children?: React.ReactNode }) => (
<p className="text-muted-foreground leading-relaxed mb-2 last:mb-0">
{children}
</p>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="list-disc list-outside ml-4 space-y-1 text-muted-foreground">
{children}
</ul>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li className="leading-relaxed">
{children}
</li>
),
code: ({ children }: { children?: React.ReactNode }) => (
<code className="px-1.5 py-0.5 rounded bg-muted text-foreground text-xs font-mono">
{children}
</code>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3 my-3 rounded-md">
<div className="flex items-start gap-2">
<InfoIcon className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200 leading-relaxed [&>p]:mb-0">
{children}
</div>
</div>
</div>
),
img: ({ src, alt, title }: { src?: string, alt?: string, title?: string }) => {
if (!src) return null;
return (
<Image
src={src}
alt={alt || ''}
title={title}
width={800}
height={600}
className="rounded-lg border border-border max-w-full h-auto my-4"
/>
);
},
};
export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps) {
const [changelog, setChangelog] = useState<ChangelogItem[]>([]);
const [loading, setLoading] = useState(!initialData);
const [error, setError] = useState<string | null>(null);
const hasFetchedRef = useRef(false);
const [previewImage, setPreviewImage] = useState<{ src: string, alt: string } | null>(null);
// Markdown component overrides for changelog rendering
const markdownComponents = useMemo(() => ({
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-sm font-semibold text-foreground mt-4 mb-2 first:mt-0">
{children}
</h3>
),
p: ({ children }: { children?: React.ReactNode }) => (
<p className="text-muted-foreground leading-relaxed mb-2 last:mb-0">
{children}
</p>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="list-disc list-outside ml-4 space-y-1 text-muted-foreground">
{children}
</ul>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li className="leading-relaxed">
{children}
</li>
),
code: ({ children }: { children?: React.ReactNode }) => (
<code className="px-1.5 py-0.5 rounded bg-muted text-foreground text-xs font-mono">
{children}
</code>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3 my-3 rounded-md">
<div className="flex items-start gap-2">
<InfoIcon className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200 leading-relaxed [&>p]:mb-0">
{children}
</div>
</div>
</div>
),
img: ({ src, alt }: { src?: string, alt?: string }) => {
if (!src) return null;
return (
<button
type="button"
onClick={() => setPreviewImage({ src, alt: alt || '' })}
className="block w-full cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 rounded-lg"
>
<Image
src={src}
alt={alt || ''}
width={800}
height={600}
className="rounded-lg border border-border max-w-full h-auto my-4 transition-opacity hover:transition-none hover:opacity-90"
/>
</button>
);
},
}), []);
const fetchChangelog = useCallback(async (signal?: AbortSignal) => {
try {
@ -172,6 +173,20 @@ export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps)
));
};
// Handle Escape key to close the image preview
useEffect(() => {
if (!previewImage) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setPreviewImage(null);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [previewImage]);
if (loading) {
return (
<div className="space-y-4">
@ -197,65 +212,100 @@ export function ChangelogWidget({ isActive, initialData }: ChangelogWidgetProps)
}
return (
<div className="space-y-4">
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Stack Auth releases</h3>
</div>
</div>
{error && (
<p className="text-xs text-destructive mt-2">
{error}
</p>
)}
</div>
<>
<div className="space-y-4">
{changelog.length === 0 && !error && (
<div className="bg-muted/30 rounded-lg p-4 text-center">
<CalendarIcon className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-xs text-muted-foreground font-medium">
No changelog entries found
</p>
</div>
)}
{changelog.map((entry) => (
<div key={entry.id} className="bg-card rounded-lg border border-border">
<div className="px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<h4 className="text-base font-semibold">v{formatVersion(entry.version)}</h4>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => toggleExpanded(entry.id)}
>
{entry.expanded ? (
<CaretUpIcon className="h-3 w-3" />
) : (
<CaretDownIcon className="h-3 w-3" />
)}
</Button>
<div className="bg-muted/30 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Stack Auth releases</h3>
</div>
{entry.expanded && (
<div className="px-4 pb-4">
<div className="text-sm leading-relaxed space-y-3">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{entry.markdown}
</ReactMarkdown>
</div>
</div>
)}
</div>
))}
{error && (
<p className="text-xs text-destructive mt-2">
{error}
</p>
)}
</div>
<div className="space-y-4">
{changelog.length === 0 && !error && (
<div className="bg-muted/30 rounded-lg p-4 text-center">
<CalendarIcon className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-xs text-muted-foreground font-medium">
No changelog entries found
</p>
</div>
)}
{changelog.map((entry) => (
<div key={entry.id} className="bg-card rounded-lg border border-border">
<div className="px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<h4 className="text-base font-semibold">{formatVersion(entry.version)}</h4>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => toggleExpanded(entry.id)}
>
{entry.expanded ? (
<CaretUpIcon className="h-3 w-3" />
) : (
<CaretDownIcon className="h-3 w-3" />
)}
</Button>
</div>
{entry.expanded && (
<div className="px-4 pb-4">
<div className="text-sm leading-relaxed space-y-3">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{entry.markdown}
</ReactMarkdown>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
{/* Image preview lightbox - rendered via portal to escape container constraints */}
{previewImage && createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={() => setPreviewImage(null)}
role="dialog"
aria-modal="true"
aria-label="Image preview"
>
<button
type="button"
onClick={() => setPreviewImage(null)}
className="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white transition-colors hover:transition-none hover:bg-black/70"
aria-label="Close preview"
>
<XIcon className="h-6 w-6" />
</button>
<div
className="relative max-w-[90vw] max-h-[90vh]"
onClick={(e) => e.stopPropagation()}
>
<Image
src={previewImage.src}
alt={previewImage.alt}
width={1600}
height={1200}
className="rounded-lg max-w-full max-h-[90vh] w-auto h-auto object-contain"
/>
</div>
</div>,
document.body
)}
</>
);
}