From 736c1a19b1930d9645af393a49d72f73da8ca7f5 Mon Sep 17 00:00:00 2001 From: Mantra <87142457+mantrakp04@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:02:43 -0700 Subject: [PATCH 1/3] make signed up at default to now (#1284) --- .../20260323000000_add_signed_up_at_default/migration.sql | 8 ++++++++ apps/backend/prisma/schema.prisma | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 apps/backend/prisma/migrations/20260323000000_add_signed_up_at_default/migration.sql diff --git a/apps/backend/prisma/migrations/20260323000000_add_signed_up_at_default/migration.sql b/apps/backend/prisma/migrations/20260323000000_add_signed_up_at_default/migration.sql new file mode 100644 index 000000000..f404be36f --- /dev/null +++ b/apps/backend/prisma/migrations/20260323000000_add_signed_up_at_default/migration.sql @@ -0,0 +1,8 @@ +-- Backward-compat: old code that doesn't know about `signedUpAt` omits it from +-- INSERT. Adding a DEFAULT lets Postgres fill it automatically. +-- +-- CURRENT_TIMESTAMP is correct here: `createdAt` also defaults to +-- CURRENT_TIMESTAMP, so within the same transaction both columns receive the +-- same value. Old code never computes risk scores, so the negligible edge +-- case of an explicitly-backdated `createdAt` is harmless. +ALTER TABLE "ProjectUser" ALTER COLUMN "signedUpAt" SET DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index ef7b4b6ae..ecb4a8c51 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -276,7 +276,7 @@ model ProjectUser { restrictedByAdminPrivateDetails String? // Private details (server access only) // Sign-up metadata - signedUpAt DateTime + signedUpAt DateTime @default(now()) signUpIp String? signUpIpTrusted Boolean? signUpEmailNormalized String? From 3efb226c5901c6ab3bc51b145b188adc2457f46b Mon Sep 17 00:00:00 2001 From: Mantra <87142457+mantrakp04@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:09:01 -0700 Subject: [PATCH 2/3] make publishable client keys truly optional ig (i hope) (#1274) ## Summary by CodeRabbit ## Documentation * Updated setup instructions across all documentation to clarify that the publishable client key is only required when your project configuration enforces it, removing confusion about unconditional requirements. --- docs/content/docs/(guides)/concepts/stack-app.mdx | 2 +- .../docs/(guides)/getting-started/setup.mdx | 4 ++-- .../api/internal/[transport]/setup-instructions.md | 14 ++++++++------ packages/stack-cli/src/lib/init-prompt.ts | 8 ++++---- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/content/docs/(guides)/concepts/stack-app.mdx b/docs/content/docs/(guides)/concepts/stack-app.mdx index 44df87786..cf0aac29f 100644 --- a/docs/content/docs/(guides)/concepts/stack-app.mdx +++ b/docs/content/docs/(guides)/concepts/stack-app.mdx @@ -39,7 +39,7 @@ function ClientComponent() { ## Client vs. server -`StackClientApp` contains everything needed to build a frontend application, for example the currently authenticated user. It requires a publishable client key in its initialization (usually set by the `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY` environment variable). +`StackClientApp` contains everything needed to build a frontend application, for example the currently authenticated user. It can use a publishable client key in its initialization (usually set by the `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY` environment variable), but that key is only required if the project is configured to require publishable client keys. `StackServerApp` has all the functionality of `StackClientApp`, but also some functions with elevated permissions, eg. listing or modifying ALL users. This requires a secret server key (usually set by the `STACK_SECRET_SERVER_KEY` environment variable), which **must always be kept secret**. diff --git a/docs/content/docs/(guides)/getting-started/setup.mdx b/docs/content/docs/(guides)/getting-started/setup.mdx index 54c756c54..cac8578a6 100644 --- a/docs/content/docs/(guides)/getting-started/setup.mdx +++ b/docs/content/docs/(guides)/getting-started/setup.mdx @@ -40,7 +40,7 @@ We recommend using our **setup wizard** for JavaScript frameworks for a seamless ### Update API keys - Create an account on [the Stack Auth dashboard](https://app.stack-auth.com/projects), create a new project with an API key, and copy its environment variables into the appropriate configuration file: + Create an account on [the Stack Auth dashboard](https://app.stack-auth.com/projects), create a new project, and copy its environment variables into the appropriate configuration file. If your project requires publishable client keys, create a project key that includes one and copy that as well. - [Register a new account on Stack](https://app.stack-auth.com/handler/sign-up), create a project in the dashboard, create a new API key from the left sidebar, and copy the project ID, publishable client key, and secret server key. + [Register a new account on Stack](https://app.stack-auth.com/handler/sign-up), create a project in the dashboard, and copy the project ID. If your project requires publishable client keys, also create a project key from the left sidebar and copy the publishable client key. For server-side setups, also copy the secret server key. ### Configure environment variables diff --git a/docs/src/app/api/internal/[transport]/setup-instructions.md b/docs/src/app/api/internal/[transport]/setup-instructions.md index a86b552b6..ad89486a3 100644 --- a/docs/src/app/api/internal/[transport]/setup-instructions.md +++ b/docs/src/app/api/internal/[transport]/setup-instructions.md @@ -46,9 +46,11 @@ Ensure they are added to the repo. #### For Next.js Projects: Required vars (from Stack dashboard): - `NEXT_PUBLIC_STACK_PROJECT_ID` -- `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY` - `STACK_SECRET_SERVER_KEY` +Optional unless your project requires publishable client keys: +- `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY` + Check `.env.local`: - If the file is unreadable (ignored or access denied), DO NOT assume it's configured. - If any required var is missing or empty, prompt the user and PAUSE. @@ -64,7 +66,8 @@ import { StackClientApp } from "@stackframe/react"; export const stackClientApp = new StackClientApp({ // You should store these in environment variables projectId: "YOUR_PROJECT_ID_HERE", - publishableClientKey: "YOUR_PUBLISHABLE_CLIENT_KEY_HERE", + // Only include this if your project requires publishable client keys + // publishableClientKey: "YOUR_PUBLISHABLE_CLIENT_KEY_HERE", tokenStore: "cookie", // redirectMethod: { // useNavigate, @@ -86,8 +89,8 @@ TODO in your web browser: 3) Choose your framework: Next.js 4) Copy these keys: - NEXT_PUBLIC_STACK_PROJECT_ID=... - - NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=... - STACK_SECRET_SERVER_KEY=... + - NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=... (only if your project requires publishable client keys) 5) Paste them into your local `.env.local` (do not commit this file). 6) Save the file. @@ -105,10 +108,10 @@ TODO in your web browser: 3) Choose your framework: React 4) Copy these keys: - Project ID - - Publishable Client Key + - Publishable Client Key (only if your project requires publishable client keys) 5) Update the `stack/client.ts` file with your keys: - Replace "YOUR_PROJECT_ID_HERE" with your Project ID - - Replace "YOUR_PUBLISHABLE_CLIENT_KEY_HERE" with your Publishable Client Key + - If needed, uncomment and replace "YOUR_PUBLISHABLE_CLIENT_KEY_HERE" with your Publishable Client Key 6) Save the file. Reply here when done: @@ -188,4 +191,3 @@ Reply with 1 or 2: If user replies `1`: Proceed to UI Installation Workflow calling the tool install UI components. If user replies `2`: Explain to the user what Stack Auth can do for him by reading our documentation using the MCP - diff --git a/packages/stack-cli/src/lib/init-prompt.ts b/packages/stack-cli/src/lib/init-prompt.ts index 97104857f..b4076e5dc 100644 --- a/packages/stack-cli/src/lib/init-prompt.ts +++ b/packages/stack-cli/src/lib/init-prompt.ts @@ -31,7 +31,7 @@ Depending on whether you're on a client or a server, you will want to create sta The stack client app has client-level permissions. It contains most of the useful methods and hooks for your client-side code. The stack server app has full read and write access to all users. It requires STACK_SECRET_SERVER_KEY env variable and should only be used in secure context -In Next.js, env vars are auto-detected (NEXT_PUBLIC_STACK_PROJECT_ID etc.), so the constructor needs no explicit config. For other frameworks, you must pass projectId and publishableClientKey explicitly using the framework's env var access method. +In Next.js, env vars are auto-detected (NEXT_PUBLIC_STACK_PROJECT_ID etc.), so the constructor needs no explicit config. For other frameworks, you must pass projectId explicitly using the framework's env var access method. Pass publishableClientKey only if your project is configured to require publishable client keys. The tokenStore should be "nextjs-cookie" for Next.js, or "cookie" for all other frameworks. @@ -44,7 +44,7 @@ import { StackClientApp } from "@stackframe/stack"; // or "@stackframe/react" or export const stackClientApp = new StackClientApp({ // Next.js: omit projectId/publishableClientKey (auto-detected from NEXT_PUBLIC_ env vars) - // Other frameworks: pass explicitly, e.g. for Vite: + // Other frameworks: pass projectId explicitly, and publishableClientKey only if required by your project. For Vite: // projectId: import.meta.env.VITE_STACK_PROJECT_ID, // publishableClientKey: import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY, tokenStore: "nextjs-cookie", // or "cookie" for non-Next.js, @@ -99,9 +99,10 @@ Rename the env var keys in .env to match the framework's convention for client-e The required variables are: - Project ID (e.g. NEXT_PUBLIC_STACK_PROJECT_ID, VITE_STACK_PROJECT_ID, etc.) -- Publishable client key (e.g. NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, VITE_STACK_PUBLISHABLE_CLIENT_KEY, etc.) - Secret server key: STACK_SECRET_SERVER_KEY (only for frameworks with server-side support, no prefix needed) +The publishable client key (e.g. NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, VITE_STACK_PUBLISHABLE_CLIENT_KEY, etc.) is only required if your project has publishable client keys enabled as a requirement. + ### 6) React only: Wrap the entire page in a Stack provider This is used for the useUser and useStackApp hooks. @@ -123,4 +124,3 @@ return ( ); \`\`\` `; - From cfa6204c2d2f6598355863afdf039c6480cdc032 Mon Sep 17 00:00:00 2001 From: Mantra <87142457+mantrakp04@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:07:37 -0700 Subject: [PATCH 3/3] Replace Web3Forms with internal feedback emails (#1244) ## Summary - replace the dashboard feedback form's Web3Forms submission with an authenticated internal backend endpoint - send support and feature-request notifications through Stack Auth's native internal email pipeline - share internal project auth headers in the dashboard and add backend E2E coverage for support feedback ## Testing - pnpm typecheck - pnpm lint -- "src/components/feedback-form.tsx" "src/components/stack-companion/feature-request-board.tsx" ## Summary by CodeRabbit * **New Features** * Internal feedback submission endpoint with automated internal email notifications * New internal email builder and sending utility; recipient list configurable via env * **Enhancements** * Feedback form requires sign-in, disables submit when unauthenticated, and tightens validation * Centralized header helper for authenticated internal requests * Feature request board gates actions for signed-out users and improves upvote/submit reliability * Runtime retrieval/validation of the feature-tracking API key and streamlined user handling * **Tests** * End-to-end tests covering internal feedback flows, validation, and email delivery --- apps/backend/.env.development | 2 + .../[featureRequestId]/upvote/route.tsx | 20 +-- .../internal/feature-requests/route.tsx | 72 +++++---- .../api/latest/internal/feedback/route.tsx | 51 +++++++ apps/backend/src/lib/featurebase.tsx | 29 ++++ .../src/lib/internal-feedback-emails.tsx | 141 ++++++++++++++++++ .../src/components/feedback-form.tsx | 33 ++-- .../stack-companion/feature-request-board.tsx | 97 ++++++------ .../src/lib/internal-project-headers.ts | 18 +++ .../api/v1/internal/feedback.test.ts | 118 +++++++++++++++ pnpm-lock.yaml | 6 + 11 files changed, 467 insertions(+), 120 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/feedback/route.tsx create mode 100644 apps/backend/src/lib/featurebase.tsx create mode 100644 apps/backend/src/lib/internal-feedback-emails.tsx create mode 100644 apps/dashboard/src/lib/internal-project-headers.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/feedback.test.ts diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 02fcc1861..8b9329ce2 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 355148293..5611bb93f 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 8224c31de..c43c50c21 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 000000000..a78048fd0 --- /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 000000000..261569e6c --- /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 000000000..d8ebb253e --- /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 3f8a64bcd..7025b536d 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 9538df8d4..ec86807a3 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 */}