diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 02fcc18611..8b9329ce29 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -88,6 +88,8 @@ STACK_EMAIL_MONITOR_SECRET_TOKEN=this-secret-token-is-for-local-development-only STACK_EMAILABLE_API_KEY= +STACK_INTERNAL_FEEDBACK_RECIPIENTS=team@stack-auth.com + # S3 Configuration for local development using s3mock STACK_S3_ENDPOINT=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21 STACK_S3_REGION=us-east-1 diff --git a/apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx b/apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx index 355148293e..5611bb93fd 100644 --- a/apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx +++ b/apps/backend/src/app/api/latest/internal/feature-requests/[featureRequestId]/upvote/route.tsx @@ -1,10 +1,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { getOrCreateFeaturebaseUserFromAuth, requireFeaturebaseApiKey } from "@/lib/featurebase"; import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import { getOrCreateFeaturebaseUser } from "@stackframe/stack-shared/dist/utils/featurebase"; - -const STACK_FEATUREBASE_API_KEY = getEnvVariable("STACK_FEATUREBASE_API_KEY", ""); // POST /api/latest/internal/feature-requests/[featureRequestId]/upvote export const POST = createSmartRouteHandler({ @@ -36,23 +33,14 @@ export const POST = createSmartRouteHandler({ }).defined(), }), handler: async ({ auth, params }) => { - if (!STACK_FEATUREBASE_API_KEY) { - throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set"); - } - - // Get or create Featurebase user for consistent email handling - const featurebaseUser = await getOrCreateFeaturebaseUser({ - id: auth.user.id, - primaryEmail: auth.user.primary_email, - displayName: auth.user.display_name, - profileImageUrl: auth.user.profile_image_url, - }); + const featurebaseApiKey = requireFeaturebaseApiKey(); + const featurebaseUser = await getOrCreateFeaturebaseUserFromAuth(auth.user); const response = await fetch('https://do.featurebase.app/v2/posts/upvoters', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-API-Key': STACK_FEATUREBASE_API_KEY, + 'X-API-Key': featurebaseApiKey, }, body: JSON.stringify({ id: params.featureRequestId, diff --git a/apps/backend/src/app/api/latest/internal/feature-requests/route.tsx b/apps/backend/src/app/api/latest/internal/feature-requests/route.tsx index 8224c31de8..c43c50c21f 100644 --- a/apps/backend/src/app/api/latest/internal/feature-requests/route.tsx +++ b/apps/backend/src/app/api/latest/internal/feature-requests/route.tsx @@ -1,10 +1,28 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { getOrCreateFeaturebaseUserFromAuth, requireFeaturebaseApiKey } from "@/lib/featurebase"; import { adaptSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import { getOrCreateFeaturebaseUser } from "@stackframe/stack-shared/dist/utils/featurebase"; -const STACK_FEATUREBASE_API_KEY = getEnvVariable("STACK_FEATUREBASE_API_KEY", ""); +// Typed subset of the Featurebase v2 API responses; fields we don't use are omitted. +// The response schema validated by yup on output acts as the runtime safety net. +type FeaturebasePost = { + id: string, + title: string, + content: string | null, + upvotes: number, + date: string, + mergedToSubmissionId: string | null, + postStatus: { name: string, color: string, type: string } | null, +}; + +type FeaturebaseUpvoter = { + userId: string, +}; + +type FeaturebaseListResponse = { + results?: T[], + error?: string, +}; // GET /api/latest/internal/feature-requests export const GET = createSmartRouteHandler({ @@ -43,27 +61,18 @@ export const GET = createSmartRouteHandler({ }).defined(), }), handler: async ({ auth }) => { - if (!STACK_FEATUREBASE_API_KEY) { - throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set"); - } - - // Get or create Featurebase user for consistent email handling - const featurebaseUser = await getOrCreateFeaturebaseUser({ - id: auth.user.id, - primaryEmail: auth.user.primary_email, - displayName: auth.user.display_name, - profileImageUrl: auth.user.profile_image_url, - }); + const featurebaseApiKey = requireFeaturebaseApiKey(); + const featurebaseUser = await getOrCreateFeaturebaseUserFromAuth(auth.user); // Fetch all posts with sorting const response = await fetch('https://do.featurebase.app/v2/posts?limit=50&sortBy=upvotes:desc', { method: 'GET', headers: { - 'X-API-Key': STACK_FEATUREBASE_API_KEY, + 'X-API-Key': featurebaseApiKey, }, }); - const data = await response.json(); + const data: FeaturebaseListResponse = await response.json(); if (!response.ok) { throw new StackAssertionError(`Featurebase API error: ${data.error || 'Failed to fetch feature requests'}`, { @@ -74,30 +83,28 @@ export const GET = createSmartRouteHandler({ }); } - const posts = data.results || []; + const posts = data.results ?? []; - // Filter out posts that have been merged into other posts or are completed - const activePosts = posts.filter((post: any) => + const activePosts = posts.filter((post) => !post.mergedToSubmissionId && post.postStatus?.type !== 'completed' ); - // Check upvote status for each post for the current user using Featurebase email const postsWithUpvoteStatus = await Promise.all( - activePosts.map(async (post: any) => { + activePosts.map(async (post) => { let userHasUpvoted = false; const upvoteResponse = await fetch(`https://do.featurebase.app/v2/posts/upvoters?submissionId=${post.id}`, { method: 'GET', headers: { - 'X-API-Key': STACK_FEATUREBASE_API_KEY, + 'X-API-Key': featurebaseApiKey, }, }); if (upvoteResponse.ok) { - const upvoteData = await upvoteResponse.json(); - const upvoters = upvoteData.results || []; - userHasUpvoted = upvoters.some((upvoter: any) => + const upvoteData: FeaturebaseListResponse = await upvoteResponse.json(); + const upvoters = upvoteData.results ?? []; + userHasUpvoted = upvoters.some((upvoter) => upvoter.userId === featurebaseUser.userId ); } @@ -156,17 +163,8 @@ export const POST = createSmartRouteHandler({ }).defined(), }), handler: async ({ auth, body }) => { - if (!STACK_FEATUREBASE_API_KEY) { - throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set"); - } - - // Get or create Featurebase user for consistent email handling - const featurebaseUser = await getOrCreateFeaturebaseUser({ - id: auth.user.id, - primaryEmail: auth.user.primary_email, - displayName: auth.user.display_name, - profileImageUrl: auth.user.profile_image_url, - }); + const featurebaseApiKey = requireFeaturebaseApiKey(); + const featurebaseUser = await getOrCreateFeaturebaseUserFromAuth(auth.user); const featurebaseRequestBody = { title: body.title, @@ -189,7 +187,7 @@ export const POST = createSmartRouteHandler({ method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-API-Key': STACK_FEATUREBASE_API_KEY, + 'X-API-Key': featurebaseApiKey, }, body: JSON.stringify(featurebaseRequestBody), }); diff --git a/apps/backend/src/app/api/latest/internal/feedback/route.tsx b/apps/backend/src/app/api/latest/internal/feedback/route.tsx new file mode 100644 index 0000000000..a78048fd07 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/feedback/route.tsx @@ -0,0 +1,51 @@ +import { sendSupportFeedbackEmail } from "@/lib/internal-feedback-emails"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, clientOrHigherAuthTypeSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Submit support feedback", + description: "Send a support feedback message to the internal Stack Auth inbox", + tags: ["Internal"], + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema, + tenancy: adaptSchema.defined(), + user: adaptSchema.defined(), + project: yupObject({ + id: yupString().oneOf(["internal"]).defined(), + }).defined(), + }).defined(), + body: yupObject({ + name: yupString().optional().max(100), + email: emailSchema.defined().nonEmpty(), + message: yupString().defined().nonEmpty().max(5000), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().oneOf([true]).defined(), + }).defined(), + }), + async handler({ auth, body }) { + await sendSupportFeedbackEmail({ + tenancy: auth.tenancy, + user: auth.user, + name: body.name ?? null, + email: body.email, + message: body.message, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + success: true, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/featurebase.tsx b/apps/backend/src/lib/featurebase.tsx new file mode 100644 index 0000000000..261569e6c3 --- /dev/null +++ b/apps/backend/src/lib/featurebase.tsx @@ -0,0 +1,29 @@ +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrCreateFeaturebaseUser as getOrCreateFeaturebaseUserShared, StackAuthUser } from "@stackframe/stack-shared/dist/utils/featurebase"; + +export function getFeaturebaseApiKey(): string { + return getEnvVariable("STACK_FEATUREBASE_API_KEY", ""); +} + +export function requireFeaturebaseApiKey(): string { + const key = getFeaturebaseApiKey(); + if (!key) { + throw new StackAssertionError("STACK_FEATUREBASE_API_KEY environment variable is not set"); + } + return key; +} + +export function toFeaturebaseUserArgs(user: UsersCrud["Admin"]["Read"]): StackAuthUser { + return { + id: user.id, + primaryEmail: user.primary_email, + displayName: user.display_name, + profileImageUrl: user.profile_image_url, + }; +} + +export async function getOrCreateFeaturebaseUserFromAuth(user: UsersCrud["Admin"]["Read"]) { + return await getOrCreateFeaturebaseUserShared(toFeaturebaseUserArgs(user)); +} diff --git a/apps/backend/src/lib/internal-feedback-emails.tsx b/apps/backend/src/lib/internal-feedback-emails.tsx new file mode 100644 index 0000000000..d8ebb253e2 --- /dev/null +++ b/apps/backend/src/lib/internal-feedback-emails.tsx @@ -0,0 +1,141 @@ +import { createTemplateComponentFromHtml } from "@/lib/email-rendering"; +import { normalizeEmail, sendEmailToMany } from "@/lib/emails"; +import { getNotificationCategoryByName } from "@/lib/notification-categories"; +import { Tenancy } from "@/lib/tenancies"; +import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { escapeHtml } from "@stackframe/stack-shared/dist/utils/html"; + +const transactionalCategoryId = getNotificationCategoryByName("Transactional")?.id ?? throwErr("Transactional notification category not found"); + +function formatTextForHtml(text: string): string { + return escapeHtml(text).replace(/\n/g, "
"); +} + +function sanitizeSubject(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function buildInternalEmailHtml(options: { + heading: string, + fields: Array<{ label: string, value: string } | { label: string, href: string, linkText: string }>, + contentLabel: string, + contentBody: string, +}): string { + const fieldRows = options.fields.map((field) => { + if ("href" in field) { + return `

${escapeHtml(field.label)}: ${escapeHtml(field.linkText)}

`; + } + return `

${escapeHtml(field.label)}: ${formatTextForHtml(field.value)}

`; + }).join("\n "); + + return ` +
+

${escapeHtml(options.heading)}

+ ${fieldRows} +
+

${escapeHtml(options.contentLabel)}

+
+ ${formatTextForHtml(options.contentBody)} +
+
+
+ `; +} + +export function getInternalFeedbackRecipients(): string[] { + const rawRecipients = getEnvVariable("STACK_INTERNAL_FEEDBACK_RECIPIENTS"); + const recipients = rawRecipients.split(",").map((recipient) => recipient.trim()); + + if (recipients.some((recipient) => recipient.length === 0)) { + throw new StackAssertionError("STACK_INTERNAL_FEEDBACK_RECIPIENTS contains an empty recipient", { + rawRecipients, + }); + } + + return [...new Set(recipients.map((recipient) => normalizeEmail(recipient)))]; +} + +async function sendInternalOperationsEmail(options: { + tenancy: Tenancy, + subject: string, + htmlContent: string, +}) { + const recipients = getInternalFeedbackRecipients(); + const tsxSource = createTemplateComponentFromHtml(options.htmlContent); + + await sendEmailToMany({ + tenancy: options.tenancy, + recipients: recipients.map((recipient) => ({ type: "custom-emails" as const, emails: [recipient] })), + tsxSource, + extraVariables: {}, + themeId: null, + isHighPriority: false, + shouldSkipDeliverabilityCheck: true, + scheduledAt: new Date(), + createdWith: { type: "programmatic-call", templateId: null }, + overrideSubject: sanitizeSubject(options.subject), + overrideNotificationCategoryId: transactionalCategoryId, + }); +} + +export async function sendSupportFeedbackEmail(options: { + tenancy: Tenancy, + user: UsersCrud["Admin"]["Read"], + name: string | null, + email: string, + message: string, +}) { + const displayName = options.name ?? options.user.display_name ?? "Not provided"; + + await sendInternalOperationsEmail({ + tenancy: options.tenancy, + subject: `[Support] ${options.email}`, + htmlContent: buildInternalEmailHtml({ + heading: "Support feedback submission", + fields: [ + { label: "Sender name", value: displayName }, + { label: "Sender email", value: options.email }, + { label: "Stack Auth user ID", value: options.user.id }, + { label: "Stack Auth display name", value: options.user.display_name ?? "Not provided" }, + ], + contentLabel: "Message", + contentBody: options.message, + }), + }); +} + +import.meta.vitest?.test("getInternalFeedbackRecipients()", ({ expect }) => { + // eslint-disable-next-line no-restricted-syntax + const previousValue = process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS; + + try { + // eslint-disable-next-line no-restricted-syntax + delete process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS; + expect(() => getInternalFeedbackRecipients()).toThrow("Missing environment variable"); + + // eslint-disable-next-line no-restricted-syntax + process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = "TEAM@stack-auth.com, team@stack-auth.com , another@example.com"; + expect(getInternalFeedbackRecipients()).toEqual([ + "team@stack-auth.com", + "another@example.com", + ]); + + // eslint-disable-next-line no-restricted-syntax + process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = "valid@example.com, "; + expect(() => getInternalFeedbackRecipients()).toThrow("empty recipient"); + + // eslint-disable-next-line no-restricted-syntax + process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = ", "; + expect(() => getInternalFeedbackRecipients()).toThrow("empty recipient"); + } finally { + if (previousValue === undefined) { + // eslint-disable-next-line no-restricted-syntax + delete process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS; + } else { + // eslint-disable-next-line no-restricted-syntax + process.env.STACK_INTERNAL_FEEDBACK_RECIPIENTS = previousValue; + } + } +}); diff --git a/apps/dashboard/src/components/feedback-form.tsx b/apps/dashboard/src/components/feedback-form.tsx index 3f8a64bcd1..7025b536de 100644 --- a/apps/dashboard/src/components/feedback-form.tsx +++ b/apps/dashboard/src/components/feedback-form.tsx @@ -1,4 +1,6 @@ import { Button } from "@/components/ui"; +import { getPublicEnvVar } from "@/lib/env"; +import { getInternalProjectHeaders } from "@/lib/internal-project-headers"; import { CheckCircleIcon, EnvelopeIcon, GithubLogoIcon, WarningCircleIcon } from "@phosphor-icons/react"; import { useUser } from "@stackframe/stack"; import { emailSchema } from "@stackframe/stack-shared/dist/schema-fields"; @@ -13,10 +15,12 @@ export function FeedbackForm() { const [submitting, setSubmitting] = useState(false); const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [errorMessage, setErrorMessage] = useState(''); + const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || ''; const domainFormSchema = yup.object({ name: yup.string() .optional() + .max(100) .label("Your name") .default(user?.displayName), email: emailSchema @@ -27,6 +31,7 @@ export function FeedbackForm() { message: yup.string() .defined() .nonEmpty("Message is required") + .max(5000) .label("Message") .meta({ type: "textarea" }), }); @@ -36,26 +41,28 @@ export function FeedbackForm() { setErrorMessage(''); try { - const response = await fetch("https://api.web3forms.com/submit", { + if (user == null) { + throw new Error("Please sign in again and retry sending feedback."); + } + const authJson = await user.getAuthJson(); + const response = await fetch(`${baseUrl}/api/v1/internal/feedback`, { method: "POST", headers: { - "Content-Type": "application/json", - Accept: "application/json", + ...getInternalProjectHeaders({ + accessToken: authJson.accessToken, + contentType: "application/json", + }), }, - body: JSON.stringify({ - ...values, - type: "feedback", - // This is the public access key, so no worries - access_key: '4f0fc468-c066-4e45-95c1-546fd652a44a', - }, null, 2), + body: JSON.stringify(values), }); if (!response.ok) { - throw new Error(`Failed to send feedback: ${response.status} ${response.statusText}`); + const responseText = await response.text(); + throw new Error(responseText || `Failed to send feedback: ${response.status} ${response.statusText}`); } - const result = await response.json(); - if (!result.success) { + const result: { success?: boolean, message?: string } = await response.json(); + if (result.success !== true) { throw new Error(result.message || 'Failed to send feedback'); } @@ -132,7 +139,7 @@ export function FeedbackForm() { form="feedback-form" className="w-full" loading={submitting} - disabled={submitting} + disabled={submitting || user == null} > Send Feedback diff --git a/apps/dashboard/src/components/stack-companion/feature-request-board.tsx b/apps/dashboard/src/components/stack-companion/feature-request-board.tsx index 9538df8d44..ec86807a33 100644 --- a/apps/dashboard/src/components/stack-companion/feature-request-board.tsx +++ b/apps/dashboard/src/components/stack-companion/feature-request-board.tsx @@ -2,12 +2,13 @@ import { Button } from '@/components/ui'; import { getPublicEnvVar } from '@/lib/env'; +import { getInternalProjectHeaders } from '@/lib/internal-project-headers'; import { cn } from '@/lib/utils'; import { CaretUpIcon, CircleNotchIcon, LightbulbIcon, PaperPlaneTiltIcon, PlusIcon, XIcon } from '@phosphor-icons/react'; import { useUser } from '@stackframe/stack'; import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors'; import { htmlToText } from '@stackframe/stack-shared/dist/utils/html'; -import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises'; +import { runAsynchronously, runAsynchronouslyWithAlert } from '@stackframe/stack-shared/dist/utils/promises'; import { useCallback, useEffect, useState } from 'react'; type FeatureRequestBoardProps = { @@ -48,7 +49,7 @@ type CreateFeatureRequestResponse = { }; export function FeatureRequestBoard({}: FeatureRequestBoardProps) { - const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); + const user = useUser(); // Base URL for API requests const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || ''; @@ -69,15 +70,17 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { // Fetch existing feature requests from secure backend const fetchFeatureRequests = useCallback(async () => { + if (user == null) { + setIsLoadingRequests(false); + return; + } + try { const authJson = await user.getAuthJson(); const response = await fetch(`${baseUrl}/api/v1/internal/feature-requests`, { - headers: { - 'X-Stack-Project-Id': 'internal', - 'X-Stack-Access-Type': 'client', - 'X-Stack-Access-Token': authJson.accessToken || '', - 'X-Stack-Publishable-Client-Key': getPublicEnvVar('NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY') || '', - }, + headers: getInternalProjectHeaders({ + accessToken: authJson.accessToken, + }), }); if (response.ok) { @@ -107,8 +110,7 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { useEffect(() => { runAsynchronously(fetchFeatureRequests()); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [fetchFeatureRequests]); // Handle refresh button click const handleRefreshRequests = () => { @@ -118,6 +120,9 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { // Handle upvote const handleUpvote = async (postId: string) => { + if (user == null) { + throw new Error("Please sign in again and retry upvoting."); + } const wasUpvoted = userUpvotes.has(postId); if (wasUpvoted) return; // sadly Featurebase doesn't currently support unvoting via the API... @@ -142,50 +147,31 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { const authJson = await user.getAuthJson(); const response = await fetch(`${baseUrl}/api/v1/internal/feature-requests/${postId}/upvote`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Stack-Project-Id': 'internal', - 'X-Stack-Access-Type': 'client', - 'X-Stack-Access-Token': authJson.accessToken || '', - 'X-Stack-Publishable-Client-Key': getPublicEnvVar('NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY') || '', - }, + headers: getInternalProjectHeaders({ + accessToken: authJson.accessToken, + contentType: 'application/json', + }), body: JSON.stringify({}), }); if (response.ok) { - // Refresh the list to get updated upvote counts from server runAsynchronously(fetchFeatureRequests()); - } else { - console.error('Failed to upvote feature request'); - // Revert optimistic updates on failure - setUserUpvotes(prev => { - const newSet = new Set(prev); - newSet.add(postId); - return newSet; - }); - setExistingRequests(prev => prev.map(request => - request.id === postId - ? { - ...request, - upvotes: request.upvotes + 1 - } - : request - )); + return; } + + throw new StackAssertionError('Failed to upvote feature request', { + status: response.status, + responseText: await response.text(), + }); } catch (error) { - console.error('Error upvoting feature request:', error); - // Revert optimistic updates on failure setUserUpvotes(prev => { const newSet = new Set(prev); - newSet.add(postId); + newSet.delete(postId); return newSet; }); setExistingRequests(prev => prev.map(request => request.id === postId - ? { - ...request, - upvotes: request.upvotes + 1 - } + ? { ...request, upvotes: request.upvotes - 1 } : request )); @@ -195,6 +181,10 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { // Submit feature request via secure backend const submitFeatureRequest = async () => { + if (user == null) { + setSubmitStatus('error'); + return; + } if (!featureTitle.trim()) return; setIsSubmitting(true); @@ -212,13 +202,10 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { const authJson = await user.getAuthJson(); const response = await fetch(`${baseUrl}/api/v1/internal/feature-requests`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Stack-Project-Id': 'internal', - 'X-Stack-Access-Type': 'client', - 'X-Stack-Access-Token': authJson.accessToken || '', - 'X-Stack-Publishable-Client-Key': getPublicEnvVar('NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY') || '', - }, + headers: getInternalProjectHeaders({ + accessToken: authJson.accessToken, + contentType: 'application/json', + }), body: JSON.stringify(requestBody) }); @@ -305,7 +292,7 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { onChange={(e) => setFeatureTitle(e.target.value)} placeholder="Brief description of your feature request..." className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" - disabled={isSubmitting} + disabled={isSubmitting || user == null} /> @@ -321,14 +308,14 @@ export function FeatureRequestBoard({}: FeatureRequestBoardProps) { placeholder="Provide more details about your feature request..." rows={3} className="w-full px-3 py-2 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none" - disabled={isSubmitting} + disabled={isSubmitting || user == null} /> {/* Submit Button */}