[Docs][API] - Updates our Docs to allow for project selection on api pages (#1058)

<!--
Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md
-->

Updates the Auth panel on API pages to allow for authenticated users to
select a project from project drop downs.

This enables easy access for the user to select their project, and test
endpoints against it.

<img width="399" height="521" alt="image"
src="https://github.com/user-attachments/assets/0d3a8444-2b69-4a21-b0ce-ce3515c4672d"
/>





<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Project selection UI for configuring admin-scoped API access
* Automatic admin-token refresh and auto-population of admin headers
from the signed-in user

* **Improvements**
* Enhanced header management (supports functional updates and clearer
handling)
* Automatic token refresh before requests and improved base URL
resolution across environments
  * Default project fallback when no project is configured
* **UX**
* clearer admin-related indicators, read-only styling, and contextual
messaging for auto-populated headers

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Madison 2025-12-22 10:07:54 -06:00 committed by GitHub
parent b6180d5912
commit ddcab1bb8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 261 additions and 41 deletions

View File

@ -1,16 +1,17 @@
'use client';
import { createContext, ReactNode, useContext, useState } from 'react';
import { createContext, ReactNode, useCallback, useContext, useState } from 'react';
import { useSidebar } from '../layouts/sidebar-context';
// Stack Auth required headers
// Note: Content-Type is NOT included here - it's added automatically by fetch when there's a body
const STACK_AUTH_HEADERS = {
'Content-Type': 'application/json',
'X-Stack-Access-Type': '', // client or server
'X-Stack-Access-Type': '', // client, server, or admin
'X-Stack-Project-Id': '', // project UUID
'X-Stack-Publishable-Client-Key': '', // pck_...
'X-Stack-Secret-Server-Key': '', // ssk_...
'X-Stack-Access-Token': '', // user's access token
'X-Stack-Admin-Access-Token': '', // admin access token (for owned projects)
};
// Type for API error objects
@ -23,9 +24,11 @@ type APIError = {
}
// Context for sharing headers across all API components on the page
type UpdateSharedHeadersInput = Record<string, string> | ((current: Record<string, string>) => Record<string, string>);
type APIPageContextType = {
sharedHeaders: Record<string, string>,
updateSharedHeaders: (headers: Record<string, string>) => void,
updateSharedHeaders: (headers: UpdateSharedHeadersInput) => void,
reportError: (status: number, error: APIError) => void,
lastError: { status: number, error: APIError } | null,
highlightMissingHeaders: boolean,
@ -57,13 +60,15 @@ export function APIPageWrapper({ children }: APIPageWrapperProps) {
toggleAuth: () => {}
};
const updateSharedHeaders = (headers: Record<string, string>) => {
setSharedHeaders(headers);
// Clear error highlighting when headers are updated
if (highlightMissingHeaders) {
setHighlightMissingHeaders(false);
}
};
const updateSharedHeaders = useCallback((headers: UpdateSharedHeadersInput) => {
setSharedHeaders(prevHeaders => {
if (typeof headers === 'function') {
return headers(prevHeaders);
}
return headers;
});
setHighlightMissingHeaders(false);
}, []);
const reportError = (status: number, error: APIError) => {
setLastError({ status, error });

View File

@ -1,17 +1,64 @@
'use client';
import { AlertTriangle, Key, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AdminOwnedProject, CurrentInternalUser, useUser } from '@stackframe/stack';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import { stringCompare } from '@stackframe/stack-shared/dist/utils/strings';
import { AlertTriangle, ChevronDown, Key, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useSidebar } from '../layouts/sidebar-context';
import { useAPIPageContext } from './api-page-wrapper';
import { Button } from './button';
type StackAuthHeaderKey =
| 'X-Stack-Access-Type'
| 'X-Stack-Project-Id'
| 'X-Stack-Publishable-Client-Key'
| 'X-Stack-Secret-Server-Key'
| 'X-Stack-Access-Token'
| 'X-Stack-Admin-Access-Token';
type StackAuthHeaderField = {
key: StackAuthHeaderKey,
label: string,
placeholder: string,
required: boolean,
hideWhenProjectSelected?: boolean,
isSensitive?: boolean,
};
const stackAuthHeaders: StackAuthHeaderField[] = [
{ key: 'X-Stack-Access-Type', label: 'Access Type', placeholder: 'client, server, or admin', required: true },
{ key: 'X-Stack-Project-Id', label: 'Project ID', placeholder: 'your-project-uuid', required: true },
{ key: 'X-Stack-Publishable-Client-Key', label: 'Client Key', placeholder: 'pck_your_key_here', required: false, hideWhenProjectSelected: true },
{ key: 'X-Stack-Secret-Server-Key', label: 'Server Key', placeholder: 'ssk_your_key_here', required: false, hideWhenProjectSelected: true },
{ key: 'X-Stack-Access-Token', label: 'Access Token', placeholder: 'user_access_token', required: false, hideWhenProjectSelected: true },
{ key: 'X-Stack-Admin-Access-Token', label: 'Admin Access Token', placeholder: 'admin_access_token', required: false, isSensitive: true },
];
type UserHookResult = ReturnType<typeof useUser>;
function isInternalUser(user: UserHookResult): user is CurrentInternalUser {
return Boolean(user && 'useOwnedProjects' in user && typeof user.useOwnedProjects === 'function');
}
export function AuthPanel() {
const sidebarContext = useSidebar();
// Always call hooks at the top level
const apiContext = useAPIPageContext();
// Get current user and their projects
// Docs and dashboard share the same authentication (internal project)
// So logged-in users will have access to their owned projects via useOwnedProjects()
const user = useUser();
const internalUser = isInternalUser(user) ? user : null;
const ownedProjectsResult = internalUser?.useOwnedProjects();
const projects = useMemo<AdminOwnedProject[]>(() => ownedProjectsResult ?? [], [ownedProjectsResult]);
const hasOwnedProjects = Boolean(internalUser);
// State for project selection
const [selectedProjectId, setSelectedProjectId] = useState<string>('');
// Use default functions if sidebar context is not available
const { isAuthOpen, toggleAuth } = sidebarContext || {
isAuthOpen: false,
@ -19,13 +66,14 @@ export function AuthPanel() {
};
// Default headers structure
const defaultHeaders: Record<string, string> = {
'Content-Type': '',
// Note: Content-Type is handled automatically by the request handler when there's a body
const defaultHeaders: Record<StackAuthHeaderKey, string> = {
'X-Stack-Access-Type': '',
'X-Stack-Project-Id': '',
'X-Stack-Publishable-Client-Key': '',
'X-Stack-Secret-Server-Key': '',
'X-Stack-Access-Token': '',
'X-Stack-Admin-Access-Token': '',
};
const { sharedHeaders, updateSharedHeaders, lastError, highlightMissingHeaders } = apiContext || {
@ -38,6 +86,26 @@ export function AuthPanel() {
// Ensure sharedHeaders is always a Record<string, string>
const headers: Record<string, string> = sharedHeaders;
// Refresh admin access token when project is selected
useEffect(() => {
if (!selectedProjectId || !user) {
return;
}
runAsynchronously(async () => {
// Get fresh access token from user's session
const authJson = await user.getAuthJson();
const adminAccessToken = authJson.accessToken ?? '';
if (adminAccessToken) {
// Update only the admin access token in headers
updateSharedHeaders(prevHeaders => ({
...prevHeaders,
'X-Stack-Admin-Access-Token': adminAccessToken,
}));
}
});
}, [selectedProjectId, user, updateSharedHeaders]);
const [isHomePage, setIsHomePage] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
@ -75,19 +143,37 @@ export function AuthPanel() {
const topPosition = isHomePage && isScrolled ? 'top-0' : 'top-0';
const height = isHomePage && isScrolled ? 'h-screen' : 'h-[calc(100vh)]';
const stackAuthHeaders = [
{ key: 'Content-Type', label: 'Content Type', placeholder: 'application/json', required: true },
{ key: 'X-Stack-Access-Type', label: 'Access Type', placeholder: 'client or server', required: true },
{ key: 'X-Stack-Project-Id', label: 'Project ID', placeholder: 'your-project-uuid', required: true },
{ key: 'X-Stack-Publishable-Client-Key', label: 'Client Key', placeholder: 'pck_your_key_here', required: false },
{ key: 'X-Stack-Secret-Server-Key', label: 'Server Key', placeholder: 'ssk_your_key_here', required: false },
{ key: 'X-Stack-Access-Token', label: 'Access Token', placeholder: 'user_access_token', required: false },
];
const missingRequiredHeaders = stackAuthHeaders.filter(
header => header.required && !headers[header.key].trim()
header => header.required && !(headers[header.key] ?? '').trim()
);
// Handle project selection
const handleProjectSelect = (projectId: string) => {
if (!projects.some((projectItem) => projectItem.id === projectId)) {
return;
}
setSelectedProjectId(projectId);
// Initial headers setup - token will be populated by the useEffect
const newHeaders = {
...headers,
'X-Stack-Access-Type': 'admin',
'X-Stack-Project-Id': projectId,
'X-Stack-Admin-Access-Token': '', // Will be populated by refresh effect
'X-Stack-Access-Token': '', // Not used for admin access
'X-Stack-Publishable-Client-Key': '', // Not needed for admin auth
'X-Stack-Secret-Server-Key': '', // Not needed for admin auth
};
updateSharedHeaders(newHeaders);
};
// Sort projects by name for better UX
const sortedProjects = useMemo(() => {
return [...projects].sort((a, b) => stringCompare(a.displayName, b.displayName));
}, [projects]);
return (
<>
{/* Desktop Auth Panel - Matching AIChatDrawer design */}
@ -150,8 +236,48 @@ export function AuthPanel() {
{/* Content - Fixed height to prevent layout shifts */}
<div className="flex-1 min-h-0">
<div className="h-full overflow-y-auto p-3 space-y-3">
{/* Project Selector - Show only if user has owned projects */}
{hasOwnedProjects && projects.length > 0 && (
<div className="space-y-1.5 pb-3 border-b border-fd-border">
<label className="text-xs font-medium text-fd-foreground flex items-center gap-2">
Quick Select Project
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
logged in
</span>
</label>
<div className="relative">
<select
value={selectedProjectId}
onChange={(e) => handleProjectSelect(e.target.value)}
className="w-full px-2 py-1.5 pr-8 border rounded-md text-xs bg-fd-background text-fd-foreground focus:outline-none focus:ring-1 focus:ring-fd-primary focus:border-fd-primary border-fd-border appearance-none cursor-pointer"
>
<option value="">Select a project...</option>
{sortedProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.displayName} ({project.id.slice(0, 8)}...)
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-fd-muted-foreground pointer-events-none" />
</div>
{selectedProjectId && (
<p className="text-xs text-green-600 dark:text-green-400">
Headers auto-populated for admin authentication
</p>
)}
</div>
)}
{/* Manual Header Inputs */}
{stackAuthHeaders.map((header) => {
const isMissing = highlightMissingHeaders && header.required && !headers[header.key].trim();
// Hide certain fields when project is selected
if (selectedProjectId && header.hideWhenProjectSelected) {
return null;
}
const value = headers[header.key] ?? '';
const isMissing = highlightMissingHeaders && header.required && !value.trim();
const isAutoPopulated = Boolean(header.isSensitive && selectedProjectId && value.length > 0);
return (
<div key={header.key} className={`space-y-1.5 ${
@ -175,14 +301,18 @@ export function AuthPanel() {
<input
type="text"
placeholder={header.placeholder}
value={headers[header.key] || ''}
value={value}
onChange={(e) => updateSharedHeaders({ ...headers, [header.key]: e.target.value })}
readOnly={isAutoPopulated}
className={`w-full px-2 py-1.5 border rounded-md text-xs bg-fd-background text-fd-foreground placeholder:text-fd-muted-foreground focus:outline-none focus:ring-1 focus:border-transparent transition-all duration-200 ${
isMissing
? 'border-red-300 focus:ring-red-500 dark:border-red-700'
: 'border-fd-border focus:ring-fd-primary focus:border-fd-primary'
}`}
} ${isAutoPopulated ? 'bg-fd-muted/50 cursor-not-allowed' : ''}`}
/>
{isAutoPopulated && (
<p className="text-xs text-fd-muted-foreground">Auto-populated from your account</p>
)}
</div>
);
})}
@ -196,7 +326,8 @@ export function AuthPanel() {
missingRequiredHeaders.length === 0 ? 'bg-green-500' : 'bg-red-500 auth-error-pulse'
}`} />
<span className="text-fd-muted-foreground">
{Object.values(headers).filter(v => v.trim()).length} of {stackAuthHeaders.length} configured
{Object.values(headers).filter(v => v.trim()).length} configured
{selectedProjectId && ' (via project selection)'}
</span>
</div>
{missingRequiredHeaders.length === 0 && Object.values(headers).some(v => v.trim()) && (
@ -271,8 +402,48 @@ export function AuthPanel() {
<div className="flex-1 min-h-0">
<div className="h-full overflow-y-auto p-3">
<div className="space-y-3">
{/* Project Selector - Mobile */}
{hasOwnedProjects && projects.length > 0 && (
<div className="space-y-1.5 pb-3 border-b border-fd-border">
<label className="text-sm font-medium text-fd-foreground flex items-center gap-2">
Quick Select Project
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
logged in
</span>
</label>
<div className="relative">
<select
value={selectedProjectId}
onChange={(e) => handleProjectSelect(e.target.value)}
className="w-full px-3 py-2 pr-8 border rounded-md text-sm bg-fd-background text-fd-foreground focus:outline-none focus:ring-1 focus:ring-fd-primary focus:border-fd-primary border-fd-border appearance-none cursor-pointer"
>
<option value="">Select a project...</option>
{sortedProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.displayName} ({project.id.slice(0, 8)}...)
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-fd-muted-foreground pointer-events-none" />
</div>
{selectedProjectId && (
<p className="text-xs text-green-600 dark:text-green-400">
Headers auto-populated for admin authentication
</p>
)}
</div>
)}
{/* Manual Header Inputs - Mobile */}
{stackAuthHeaders.map((header) => {
const isMissing = highlightMissingHeaders && header.required && !headers[header.key].trim();
// Hide certain fields when project is selected
if (selectedProjectId && header.hideWhenProjectSelected) {
return null;
}
const value = headers[header.key] ?? '';
const isMissing = highlightMissingHeaders && header.required && !value.trim();
const isAutoPopulated = Boolean(header.isSensitive && selectedProjectId && value.length > 0);
return (
<div key={header.key} className={`space-y-1.5 ${
@ -296,14 +467,18 @@ export function AuthPanel() {
<input
type="text"
placeholder={header.placeholder}
value={headers[header.key] || ''}
value={value}
onChange={(e) => updateSharedHeaders({ ...headers, [header.key]: e.target.value })}
readOnly={isAutoPopulated}
className={`w-full px-3 py-2 border rounded-md text-sm bg-fd-background text-fd-foreground placeholder:text-fd-muted-foreground focus:outline-none focus:ring-1 focus:border-transparent transition-all duration-200 ${
isMissing
? 'border-red-300 focus:ring-red-500 dark:border-red-700'
: 'border-fd-border focus:ring-fd-primary focus:border-fd-primary'
}`}
} ${isAutoPopulated ? 'bg-fd-muted/50 cursor-not-allowed' : ''}`}
/>
{isAutoPopulated && (
<p className="text-xs text-fd-muted-foreground">Auto-populated from your account</p>
)}
</div>
);
})}
@ -319,7 +494,8 @@ export function AuthPanel() {
missingRequiredHeaders.length === 0 ? 'bg-green-500' : 'bg-red-500 auth-error-pulse'
}`} />
<span className="text-fd-muted-foreground">
{Object.values(headers).filter(v => v.trim()).length} of {stackAuthHeaders.length} configured
{Object.values(headers).filter(v => v.trim()).length} configured
{selectedProjectId && ' (via project)'}
</span>
</div>
<Button onClick={toggleAuth} className="text-xs px-3 py-1">

View File

@ -1,5 +1,6 @@
'use client';
import { useUser } from '@stackframe/stack';
import { ArrowRight, Check, Code, Copy, Play, Send, Settings, Zap } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import type { OpenAPIOperation, OpenAPIParameter, OpenAPISchema, OpenAPISpec } from '../../lib/openapi-types';
@ -42,11 +43,13 @@ const HTTP_METHOD_COLORS = {
export function EnhancedAPIPage({ document, operations, description }: EnhancedAPIPageProps) {
const apiContext = useAPIPageContext();
const user = useUser(); // Get user for token refresh
// Use default functions if API context is not available
const { sharedHeaders, reportError } = apiContext || {
const { sharedHeaders, reportError, updateSharedHeaders } = apiContext || {
sharedHeaders: {},
reportError: () => {}
reportError: () => {},
updateSharedHeaders: () => {}
};
const [spec, setSpec] = useState<OpenAPISpec | null>(null);
@ -205,7 +208,26 @@ export function EnhancedAPIPage({ document, operations, description }: EnhancedA
}));
try {
const baseUrl = spec?.servers?.[0]?.url || '';
let headersForRequest = requestState.headers;
// Refresh admin access token before making request (if using admin auth)
if (user && headersForRequest['X-Stack-Access-Type'] === 'admin') {
const authJson = await user.getAuthJson();
if (authJson.accessToken) {
// Update the admin access token with a fresh one
const refreshedHeaders: Record<string, string> = {
...headersForRequest,
'X-Stack-Admin-Access-Token': authJson.accessToken,
};
setRequestState(prev => ({ ...prev, headers: refreshedHeaders }));
updateSharedHeaders(refreshedHeaders);
headersForRequest = refreshedHeaders;
}
}
// Use local API URL in development, production URL from OpenAPI spec otherwise
const defaultBaseUrl = spec?.servers?.[0]?.url || '';
const localApiUrl = process.env.NEXT_PUBLIC_STACK_API_URL;
const baseUrl = localApiUrl ? localApiUrl + '/api/v1' : defaultBaseUrl;
let url = baseUrl + path;
// Replace path parameters
@ -231,7 +253,7 @@ export function EnhancedAPIPage({ document, operations, description }: EnhancedA
// Filter out empty headers
const filteredHeaders = Object.fromEntries(
Object.entries(requestState.headers).filter(([key, value]) => key && value)
Object.entries(headersForRequest).filter(([key, value]) => key && value)
);
const requestOptions: RequestInit = {
@ -247,6 +269,11 @@ export function EnhancedAPIPage({ document, operations, description }: EnhancedA
);
if (Object.keys(bodyData).length > 0) {
requestOptions.body = JSON.stringify(bodyData);
// Add Content-Type header when sending JSON body
requestOptions.headers = {
...filteredHeaders,
'Content-Type': 'application/json',
};
}
}
@ -286,7 +313,7 @@ export function EnhancedAPIPage({ document, operations, description }: EnhancedA
}
}));
}
}, [spec, requestState.parameters, requestState.headers, requestState.bodyFields, reportError]); // Changed from requestState.body
}, [spec, requestState.parameters, requestState.headers, requestState.bodyFields, reportError, user, updateSharedHeaders]);
if (loading) {
return (
@ -387,7 +414,11 @@ function ModernAPIPlayground({
};
const generateCurlCommand = useCallback(() => {
const baseUrl = spec.servers?.[0]?.url || '';
// Use local API URL in development, production URL otherwise
const defaultBaseUrl = spec.servers?.[0]?.url || '';
const baseUrl = process.env.NEXT_PUBLIC_STACK_API_URL
? process.env.NEXT_PUBLIC_STACK_API_URL + '/api/v1'
: defaultBaseUrl;
let url = baseUrl + path;
// Replace path parameters
@ -433,7 +464,11 @@ function ModernAPIPlayground({
return curlCommand;
}, [operation, path, method, spec, requestState]);
const generateJavaScriptCode = useCallback(() => {
const baseUrl = spec.servers?.[0]?.url || '';
// Use local API URL in development, production URL otherwise
const defaultBaseUrl = spec.servers?.[0]?.url || '';
const baseUrl = process.env.NEXT_PUBLIC_STACK_API_URL
? process.env.NEXT_PUBLIC_STACK_API_URL + '/api/v1'
: defaultBaseUrl;
let url = baseUrl + path;
// Replace path parameters
@ -483,7 +518,11 @@ function ModernAPIPlayground({
}, [operation, path, method, spec, requestState]);
const generatePythonCode = useCallback(() => {
const baseUrl = spec.servers?.[0]?.url || '';
// Use local API URL in development, production URL otherwise
const defaultBaseUrl = spec.servers?.[0]?.url || '';
const baseUrl = process.env.NEXT_PUBLIC_STACK_API_URL
? process.env.NEXT_PUBLIC_STACK_API_URL + '/api/v1'
: defaultBaseUrl;
let url = baseUrl + path;
// Replace path parameters