From 4b06bca59e9a15960238396988da2a6d42cf226b Mon Sep 17 00:00:00 2001 From: Madison Date: Thu, 21 Aug 2025 12:05:48 -0500 Subject: [PATCH 1/2] init emails docs (#848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates existing docs to include emails endpoints, and adds new docs for emails in general docs, as well as SDK docs. ---- > [!IMPORTANT] > Introduces server-side email sending API and updates documentation to include comprehensive guides and SDK references for email functionality. > > - **Behavior**: > - Introduces `sendEmail` API in `route.tsx` for sending emails with HTML or templates. > - Handles errors like missing user IDs and schema errors. > - **Documentation**: > - Adds `concepts/emails.mdx` detailing email types, sending methods, and configuration. > - Updates `docs-platform.yml` and `meta.json` to include email documentation. > - Adds `sdk/types/email.mdx` for `SendEmailOptions` type reference. > - **UI/Style**: > - Adds badge style for `sendEmailOptions` in `method-layout.tsx`. > > This description was created by [Ellipsis](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral) for 2edeb577347c9fa040a10638495758420f4dd1c0. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed. ---- ## Summary by CodeRabbit * **New Features** * Introduced server-side email sending API with templates, theming, variables, and notification categories. * **Documentation** * Added comprehensive Emails concept guide and SDK references (sendEmail, SendEmailOptions). * Extended SDK index and platform navigation to include Email docs for Next/React/JS. * Added an “Emails” functional tag to API docs and route metadata. * **Style** * Added a distinct badge style for SendEmailOptions in the docs UI. --------- Co-authored-by: Konsti Wohlwend --- .../api/latest/emails/send-email/route.tsx | 6 +- docs/docs-platform.yml | 6 + docs/scripts/generate-functional-api-docs.mjs | 1 + docs/src/components/ui/method-layout.tsx | 3 + docs/templates/concepts/emails.mdx | 197 +++++++++++++++++ docs/templates/meta.json | 1 + docs/templates/sdk/index.mdx | 6 + docs/templates/sdk/meta.json | 1 + docs/templates/sdk/objects/stack-app.mdx | 72 +++++++ docs/templates/sdk/types/email.mdx | 201 ++++++++++++++++++ 10 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 docs/templates/concepts/emails.mdx create mode 100644 docs/templates/sdk/types/email.mdx diff --git a/apps/backend/src/app/api/latest/emails/send-email/route.tsx b/apps/backend/src/app/api/latest/emails/send-email/route.tsx index 77ccf24a3..363ac1a48 100644 --- a/apps/backend/src/app/api/latest/emails/send-email/route.tsx +++ b/apps/backend/src/app/api/latest/emails/send-email/route.tsx @@ -3,12 +3,11 @@ import { getEmailConfig, sendEmail } from "@/lib/emails"; import { getNotificationCategoryByName, hasNotificationEnabled } from "@/lib/notification-categories"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, serverOrHigherAuthTypeSchema, templateThemeIdSchema, yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { unsubscribeLinkVerificationCodeHandler } from "../unsubscribe-link/verification-handler"; -import { KnownErrors } from "@stackframe/stack-shared"; type UserResult = { user_id: string, @@ -19,6 +18,7 @@ export const POST = createSmartRouteHandler({ metadata: { summary: "Send email", description: "Send an email to a list of users. The content field should contain either {html, subject, notification_category_name} for HTML emails or {template_id, variables} for template-based emails.", + tags: ["Emails"], }, request: yupObject({ auth: yupObject({ diff --git a/docs/docs-platform.yml b/docs/docs-platform.yml index 663ad7ab3..5afdbc846 100644 --- a/docs/docs-platform.yml +++ b/docs/docs-platform.yml @@ -110,6 +110,9 @@ pages: - path: concepts/backend-integration.mdx platforms: ["next", "react", "js", "python"] + + - path: concepts/emails.mdx + platforms: ["next", "react", "js"] # No Python (server-side email functionality) # Components (React-like only) - path: components/overview.mdx @@ -227,6 +230,9 @@ pages: - path: sdk/types/user.mdx platforms: ["next", "react", "js"] # No Python + + - path: sdk/types/email.mdx + platforms: ["next", "react", "js"] # No Python # SDK Hooks (React-like only) - path: sdk/hooks/use-stack-app.mdx diff --git a/docs/scripts/generate-functional-api-docs.mjs b/docs/scripts/generate-functional-api-docs.mjs index aa9d91f86..7c247f28c 100644 --- a/docs/scripts/generate-functional-api-docs.mjs +++ b/docs/scripts/generate-functional-api-docs.mjs @@ -13,6 +13,7 @@ const FUNCTIONAL_TAGS = [ 'API Keys', 'CLI Authentication', 'Contact Channels', + 'Emails', 'Oauth', // Note: OpenAPI uses "Oauth" not "OAuth" 'OTP', 'Password', diff --git a/docs/src/components/ui/method-layout.tsx b/docs/src/components/ui/method-layout.tsx index a5ff461f6..e30a18ed9 100644 --- a/docs/src/components/ui/method-layout.tsx +++ b/docs/src/components/ui/method-layout.tsx @@ -376,6 +376,9 @@ export function CollapsibleTypesSection({ case 'project': { return 'bg-lime-50 dark:bg-lime-950/50 text-lime-700 dark:text-lime-300'; } + case 'sendemailoptions': { + return 'bg-rose-50 dark:bg-rose-950/50 text-rose-700 dark:text-rose-300'; + } default: { return 'bg-gray-50 dark:bg-gray-950/50 text-gray-700 dark:text-gray-300'; } diff --git a/docs/templates/concepts/emails.mdx b/docs/templates/concepts/emails.mdx new file mode 100644 index 000000000..b5a12fa5a --- /dev/null +++ b/docs/templates/concepts/emails.mdx @@ -0,0 +1,197 @@ +--- +title: Emails +description: Send custom emails to your users with Stack Auth's email system. +--- + +Stack Auth provides emails that allows you to send custom emails to your users. The system supports both custom HTML emails and template-based emails with theming. + +## Email Types: +There are two types of emails that you can send to your users: +- **Transactional Emails**: Transactional emails are those required for your user to use your application. These emails cannot be opted out of. +- **Marketing Emails**: Marketing emails always contain an unsubscribe link and may be more general marketing material related to your application/company. + + +Never send marketing emails as transactional emails, as this can quickly lead to your domain being blacklisted by email spam filters. + + + + +## Overview + +The email system provides: + +- **Email Sending**: Send custom emails to users via the `sendEmail` method on `StackServerApp` +- **Email Templates**: Use predefined email templates for common authentication flows +- **Email Themes**: Apply consistent styling to your emails +- **Notification Categories**: Allow users to control which emails they receive + +## Server-Side Email Sending + +### Basic Email Sending + +Use the `sendEmail` method on your server app to send emails to users: + +```typescript +import { stackServerApp } from './stack'; + +// Send a custom HTML email +const result = await stackServerApp.sendEmail({ + userIds: ['user-id-1', 'user-id-2'], + subject: 'Welcome to our platform!', + html: '

Welcome!

Thanks for joining us.

', +}); + +if (result.status === 'error') { + console.error('Failed to send email:', result.error); +} +``` + +### Template-Based Emails + +Send emails using predefined templates with variables: + +```typescript +// Send email using a template +const result = await stackServerApp.sendEmail({ + userIds: ['user-id'], + templateId: 'welcome-template', + subject: 'Welcome to our platform!', + variables: { + userName: 'John Doe', + activationUrl: 'https://yourapp.com/activate/token123', + supportEmail: 'support@yourapp.com', + }, +}); +``` + +### Email Options + +The `sendEmail` method accepts the following options: + +```typescript +type SendEmailOptions = { + userIds: string[]; // Array of user IDs to send to + themeId?: string | null | false; // Theme to apply (optional) + subject?: string; // Email subject + notificationCategoryName?: string; // Notification category for user preferences + html?: string; // Custom HTML content + templateId?: string; // Template ID to use + variables?: Record; // Template variables +}; +``` + +### Error Handling + +The `sendEmail` method returns a `Result` type that can contain specific errors: + +```typescript +const result = await stackServerApp.sendEmail({ + userIds: ['user-id'], + html: '

Hello!

', + subject: 'Test Email', +}); + +if (result.status === 'error') { + switch (result.error.code) { + case 'REQUIRES_CUSTOM_EMAIL_SERVER': + console.error('Please configure a custom email server'); + break; + case 'SCHEMA_ERROR': + console.error('Invalid email data provided'); + break; + case 'USER_ID_DOES_NOT_EXIST': + console.error('One or more user IDs do not exist'); + break; + } +} +``` + + + +## Built-in Email Templates + +Stack Auth provides several built-in email templates for common authentication flows: + +- **Email Verification**: `email_verification` - Sent when users need to verify their email +- **Password Reset**: `password_reset` - Sent when users request password reset +- **Magic Link**: `magic_link` - Sent for passwordless authentication +- **Team Invitation**: `team_invitation` - Sent when users are invited to teams +- **Sign-in Invitation**: `sign_in_invitation` - Sent to invite users to sign up + +These templates can be customized through the admin interface or programmatically. + +## Email Configuration + +Email configuration is managed through the Stack Auth dashboard or admin API, not directly in your application code. You have two options: + +### Shared Email Provider (Development) + +For development and testing, you can use Stack's shared email provider. This sends emails from `noreply@stackframe.co` and is configured through your project settings in the Stack Auth dashboard. + +- Go to your project's Email settings in the dashboard +- Select "Shared" as your email server type +- No additional configuration required + +### Custom Email Server (Production) + +For production, configure your own SMTP server through the dashboard: + +- Go to your project's Email settings in the dashboard +- Select "Custom SMTP server" as your email server type +- Configure the following settings: + - **Host**: Your SMTP server hostname (e.g., `smtp.yourprovider.com`) + - **Port**: SMTP port (typically 587 for TLS or 465 for SSL) + - **Username**: Your SMTP username + - **Password**: Your SMTP password + - **Sender Email**: The email address emails will be sent from + - **Sender Name**: The display name for your emails + +The dashboard will automatically test your configuration when you save it. + +## Notification Categories + +Control which emails users receive by organizing them into notification categories: + +```typescript +await stackServerApp.sendEmail({ + userIds: ['user-id'], + html: '

New feature available!

', + subject: 'Product Updates', + notificationCategoryName: 'product_updates', +}); +``` + +Users can then opt in or out of specific notification categories through their account settings. + +## Best Practices + +1. **Use Templates**: Leverage built-in templates for consistent branding and easier maintenance +2. **Handle Errors**: Always check the result status and handle potential errors +3. **Respect User Preferences**: Use notification categories to let users control what emails they receive +4. **Server-Side Only**: Always send emails from your server-side code, never from the client + +## React Components Integration + +Emails integrates seamlessly with Stack Auth's React components. Email verification, password reset, and other authentication emails are automatically sent when users interact with the provided components. + +For custom email flows, use the `sendEmail` method from your server-side code: + +```typescript +// In your API route or server action +import { stackServerApp } from '@stackframe/stack'; + +export async function inviteUser(email: string) { + const result = await stackServerApp.sendEmail({ + userIds: [userId], // Get user ID first + templateId: 'invitation-template', + subject: 'You\'re invited!', + variables: { + inviteUrl: 'https://yourapp.com/invite/token123', + }, + }); + + return result; +} +``` + +This email system gives you control over your application's email communications while maintaining the security and reliability of Stack Auth's infrastructure. diff --git a/docs/templates/meta.json b/docs/templates/meta.json index ac1bf07a0..a8f5a5178 100644 --- a/docs/templates/meta.json +++ b/docs/templates/meta.json @@ -14,6 +14,7 @@ "concepts/api-keys", "concepts/backend-integration", "concepts/custom-user-data", + "concepts/emails", "concepts/oauth", "concepts/auth-providers", "concepts/orgs-and-teams", diff --git a/docs/templates/sdk/index.mdx b/docs/templates/sdk/index.mdx index 3b5b6e7ac..e3da074f9 100644 --- a/docs/templates/sdk/index.mdx +++ b/docs/templates/sdk/index.mdx @@ -40,6 +40,12 @@ export const sdkSections = [ { name: "ServerTeamProfile", href: "types/team-profile#serverteamprofile", icon: "type" }, ] }, + { + title: "Email", + items: [ + { name: "SendEmailOptions", href: "types/email#sendemailoptions", icon: "type" }, + ] + }, { title: "Hooks", items: [ diff --git a/docs/templates/sdk/meta.json b/docs/templates/sdk/meta.json index 391e9852e..0e64521f0 100644 --- a/docs/templates/sdk/meta.json +++ b/docs/templates/sdk/meta.json @@ -14,6 +14,7 @@ "types/team-permission", "types/team-profile", "types/contact-channel", + "types/email", "types/api-key", "types/project", "types/connected-account", diff --git a/docs/templates/sdk/objects/stack-app.mdx b/docs/templates/sdk/objects/stack-app.mdx index 874287658..7b2e9c10d 100644 --- a/docs/templates/sdk/objects/stack-app.mdx +++ b/docs/templates/sdk/objects/stack-app.mdx @@ -525,6 +525,7 @@ exposing [`SECRET_SERVER_KEY`](../../rest-api/overview.mdx) on the client. // NEXT_LINE_PLATFORM react-like ⤷ useUsers([options]): ServerUser[]; //$stack-link-to:#stackserverappuseusersoptions createUser([options]): Promise; //$stack-link-to:#stackserverappcreateuseroptions + sendEmail(options): Promise>; //$stack-link-to:#stackserverappsendemailoptions getTeam(id): Promise; //$stack-link-to:#stackserverappgetteamid // NEXT_LINE_PLATFORM react-like @@ -799,6 +800,77 @@ const user = await stackServerApp.createUser({ + + + + + + Send custom emails to users. You can send either custom HTML emails or use predefined templates with variables. + + **Parameters:** + - `options` ([SendEmailOptions](../types/email#sendemailoptions)) - Email configuration and content + + **Returns:** `Promise>` + + The method returns a `Result` object that can contain specific error types: + + - `RequiresCustomEmailServer` - No custom email server configured + - `SchemaError` - Invalid email data provided + - `UserIdDoesNotExist` - One or more user IDs don't exist + + + + + + +```typescript +declare function sendEmail(options: SendEmailOptions): Promise>; +``` + + + + + + Send HTML Email + Send Template Email + + +```typescript +const result = await stackServerApp.sendEmail({ + userIds: ['user-1', 'user-2'], + subject: 'Welcome to our platform!', + html: '

Welcome!

Thanks for joining us.

', +}); + +if (result.status === 'error') { + console.error('Failed to send email:', result.error); +} +``` +
+ +```typescript +const result = await stackServerApp.sendEmail({ + userIds: ['user-1'], + templateId: 'welcome-template', + variables: { + userName: 'John Doe', + activationUrl: 'https://app.com/activate/token123', + }, +}); + +if (result.status === 'error') { + console.error('Failed to send email:', result.error); +} +``` + +
+ +
+ +
+
+
+ ## Team Management diff --git a/docs/templates/sdk/types/email.mdx b/docs/templates/sdk/types/email.mdx new file mode 100644 index 000000000..9bca39435 --- /dev/null +++ b/docs/templates/sdk/types/email.mdx @@ -0,0 +1,201 @@ +--- +title: Email +full: true +--- + +This is a detailed reference for email-related types in Stack Auth. If you're looking for a more high-level overview, please refer to our [guide on the email system](../../concepts/emails.mdx). + +On this page: +- [SendEmailOptions](#sendemailoptions) + +--- + +# `SendEmailOptions` + +Options for sending emails via the `sendEmail` method on `StackServerApp`. + +### Table of Contents + +; //$stack-link-to:#sendemailoptionsvariables +};`} /> + + + + + An array of user IDs that will receive the email. All users must exist in your Stack Auth project. + + + + ```typescript + userIds: string[] + ``` + + + ```typescript + { + userIds: ['user-1', 'user-2', 'user-3'], + // ... other options + } + ``` + + + + + + + + + Optional theme ID to apply to the email. Use `null` for no theme, `false` to use the default theme, or a string ID for a specific theme. + + + + ```typescript + themeId?: string | null | false + ``` + + + ```typescript + { + themeId: 'corporate-theme-id', + // or + themeId: null, // no theme + // or + themeId: false, // default theme + // ... other options + } + ``` + + + + + + + + + Optional email subject line. If using a template, this overrides the template's default subject. + + + + ```typescript + subject?: string + ``` + + + ```typescript + { + subject: 'Welcome to our platform!', + // ... other options + } + ``` + + + + + + + + + Optional notification category name for user preferences. Users can opt in or out of specific categories through their account settings. + + + + ```typescript + notificationCategoryName?: string + ``` + + + ```typescript + { + notificationCategoryName: 'product_updates', + // ... other options + } + ``` + + + + + + + + + Custom HTML content for the email. Use this option when you want to send a custom HTML email instead of using a template. Cannot be used together with `templateId` or `variables`. + + + + ```typescript + html?: string + ``` + + + ```typescript + { + userIds: ['user-1'], + html: '

Welcome!

Thanks for joining us.

', + subject: 'Welcome to our platform' + } + ``` +
+
+
+
+ + + + + ID of the email template to use. Use this option when you want to send a template-based email with variables. Cannot be used together with `html`. + + + + ```typescript + templateId?: string + ``` + + + ```typescript + { + userIds: ['user-1'], + templateId: 'welcome-template', + variables: { + userName: 'John Doe', + activationUrl: 'https://app.com/activate/token123' + } + } + ``` + + + + + + + + + Optional variables to substitute in the template. Only used when `templateId` is provided. + + + + ```typescript + variables?: Record + ``` + + + ```typescript + { + templateId: 'welcome-template', + variables: { + userName: 'John Doe', + activationUrl: 'https://app.com/activate/token123', + supportEmail: 'support@yourapp.com' + } + } + ``` + + + + From 301398f4cca9b747ea2fda6229faa8feb9d879c7 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 21 Aug 2025 16:05:28 -0700 Subject: [PATCH 2/2] Project transfers --- .claude/CLAUDE-KNOWLEDGE.md | 120 +++++- .../internal/projects/transfer/route.tsx | 90 ++++ .../project-settings/page-client.tsx | 90 ++++ .../api/v1/internal/projects/transfer.test.ts | 391 ++++++++++++++++++ .../src/interface/admin-interface.ts | 17 + .../template/src/components/team-switcher.tsx | 4 +- .../apps/implementations/admin-app-impl.ts | 6 +- .../src/lib/stack-app/projects/index.ts | 2 + 8 files changed, 716 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/projects/transfer/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/projects/transfer.test.ts diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 9857ba119..2f1794fa6 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -122,4 +122,122 @@ A: ESLint may remove "unused" imports. Always verify your changes after auto-fix A: Missing newline at end of file. ESLint requires files to end with a newline character. ### Q: How do you handle TypeScript errors about missing exports? -A: Double-check that you're only importing what's actually exported from a module. The error "Module declares 'X' locally, but it is not exported" means you're trying to import something that isn't exported. \ No newline at end of file +A: Double-check that you're only importing what's actually exported from a module. The error "Module declares 'X' locally, but it is not exported" means you're trying to import something that isn't exported. + +## Project Transfer Implementation + +### Q: How do I add a new API endpoint to the internal project? +A: Create a new route file in `/apps/backend/src/app/api/latest/internal/` using the `createSmartRouteHandler` pattern. Internal endpoints should check `auth.project.id === "internal"` and throw `KnownErrors.ExpectedInternalProject()` if not. + +### Q: How do team permissions work in Stack Auth? +A: Team permissions are defined in `/apps/backend/src/lib/permissions.tsx`. The permission `team_admin` (not `$team_admin`) is a normal permission that happens to be defined by default on the internal project. Use `ensureUserTeamPermissionExists` to check if a user has a specific permission. + +### Q: How do I check team permissions in the backend? +A: Use `ensureUserTeamPermissionExists` from `/apps/backend/src/lib/request-checks.tsx`. Example: +```typescript +await ensureUserTeamPermissionExists(prisma, { + tenancy: internalTenancy, + teamId: teamId, + userId: userId, + permissionId: "team_admin", + errorType: "required", + recursive: true, +}); +``` + +### Q: How do I add new functionality to the admin interface? +A: Don't use server actions. Instead, implement the endpoint functions on the admin-app and admin-interface. Add methods to the AdminProject class in the SDK packages that call the backend API endpoints. + +### Q: How do I use TeamSwitcher component in the dashboard? +A: Import `TeamSwitcher` from `@stackframe/stack` and use it like: +```typescript + { + setSelectedTeamId(team.id); + }} +/> +``` + +### Q: How do I write E2E tests for backend endpoints? +A: Import `it` from helpers (not vitest), and set up the project context inside each test: +```typescript +import { describe } from "vitest"; +import { it } from "../../../../../../helpers"; +import { Auth, Project, backendContext, niceBackendFetch, InternalProjectKeys } from "../../../../../backend-helpers"; + +it("test name", async ({ expect }) => { + backendContext.set({ projectKeys: InternalProjectKeys }); + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + // test logic +}); +``` + +### Q: Where is project ownership stored in the database? +A: Projects have an `ownerTeamId` field in the Project model (see `/apps/backend/prisma/schema.prisma`). This links to a team in the internal project. + +### Q: How do I make authenticated API calls from dashboard server actions? +A: Get the session cookie and include it in the request headers: +```typescript +const cookieStore = await cookies(); +const sessionCookie = cookieStore.get("stack-refresh-internal"); +const response = await fetch(url, { + headers: { + 'X-Stack-Access-Type': 'server', + 'X-Stack-Project-Id': 'internal', + 'X-Stack-Secret-Server-Key': getEnvVariable('STACK_SECRET_SERVER_KEY'), + ...(sessionCookie ? { 'Cookie': `${sessionCookie.name}=${sessionCookie.value}` } : {}) + } +}); +``` + +### Q: What's the difference between ensureTeamMembershipExists and ensureUserTeamPermissionExists? +A: `ensureTeamMembershipExists` only checks if a user is a member of a team. `ensureUserTeamPermissionExists` checks if a user has a specific permission (like `team_admin`) within that team. The latter also calls `ensureTeamMembershipExists` internally. + +### Q: How do I handle errors in the backend API? +A: Use `KnownErrors` from `@stackframe/stack-shared` for standard errors (e.g., `KnownErrors.ProjectNotFound()`). For custom errors, use `StatusError` from `@stackframe/stack-shared/dist/utils/errors` with an HTTP status code and message. + +### Q: What's the pattern for TypeScript schema validation in API routes? +A: Use yup schemas from `@stackframe/stack-shared/dist/schema-fields`. Don't use regular yup imports. Example: +```typescript +import { yupObject, yupString, yupNumber } from "@stackframe/stack-shared/dist/schema-fields"; +``` + +### Q: How are teams and projects related in Stack Auth? +A: Projects belong to teams via the `ownerTeamId` field. Teams exist within the internal project. Users can be members of multiple teams and have different permissions in each team. + +### Q: How do I properly escape quotes in React components to avoid lint errors? +A: Use template literals with backticks instead of quotes in JSX text content: +```typescript +{`Text with "quotes" inside`} +``` + +### Q: What auth headers are needed for internal API calls? +A: Internal API calls need: +- `X-Stack-Access-Type: 'server'` +- `X-Stack-Project-Id: 'internal'` +- `X-Stack-Secret-Server-Key: ` +- Either `X-Stack-Auth: Bearer ` or a session cookie + +### Q: How do I reload the page after a successful action in the dashboard? +A: Use `window.location.reload()` after the action completes. This ensures the UI reflects the latest state from the server. + +### Q: What's the file structure for API routes in the backend? +A: Routes follow Next.js App Router conventions in `/apps/backend/src/app/api/latest/`. Each route has a `route.tsx` file that exports HTTP method handlers (GET, POST, etc.). + +### Q: How do I get all teams a user is a member of in the dashboard? +A: Use `user.useTeams()` where `user` is from `useUser({ or: 'redirect', projectIdMustMatch: "internal" })`. + +### Q: What's the difference between client and server access types? +A: Client access type is for frontend applications and has limited permissions. Server access type is for backend operations and requires a secret key. Admin access type is for dashboard operations with full permissions. + +### Q: How to avoid TypeScript "unnecessary conditional" errors when checking auth.user? +A: If the schema defines `auth.user` as `.defined()`, TypeScript knows it can't be null, so checking `if (!auth.user)` causes a lint error. Remove the check or adjust the schema if the field can be undefined. + +### Q: What to do when TypeScript can't find module '@stackframe/stack' declarations? +A: This happens when packages haven't been built yet. Run these commands in order: +```bash +pnpm clean && pnpm i && pnpm codegen && pnpm build:packages +``` +Then restart the dev server. This rebuilds all packages and generates the necessary TypeScript declarations. \ No newline at end of file diff --git a/apps/backend/src/app/api/latest/internal/projects/transfer/route.tsx b/apps/backend/src/app/api/latest/internal/projects/transfer/route.tsx new file mode 100644 index 000000000..59b49f615 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/projects/transfer/route.tsx @@ -0,0 +1,90 @@ +import { ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + project: yupObject({ + id: yupString().oneOf(["internal"]).defined(), + }).defined(), + user: yupObject({ + id: yupString().defined(), + }).defined(), + }).defined(), + body: yupObject({ + project_id: yupString().defined(), + new_team_id: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupString().oneOf(["true"]).defined(), + }).defined(), + }), + handler: async (req) => { + const { auth, body } = req; + + const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID); + const internalPrisma = await getPrismaClientForTenancy(internalTenancy); + + // Get the project to transfer + const projectToTransfer = await globalPrismaClient.project.findUnique({ + where: { + id: body.project_id, + }, + }); + + if (!projectToTransfer) { + throw new KnownErrors.ProjectNotFound(body.project_id); + } + + if (!projectToTransfer.ownerTeamId) { + throw new StatusError(400, "Project must have an owner team to be transferred"); + } + + // Check if user is a team admin of the current owner team + await ensureUserTeamPermissionExists(internalPrisma, { + tenancy: internalTenancy, + teamId: projectToTransfer.ownerTeamId, + userId: auth.user.id, + permissionId: "team_admin", + errorType: "required", + recursive: true, + }); + + // Check if user is a member of the new team (doesn't need to be admin) + await ensureTeamMembershipExists(internalPrisma, { + tenancyId: internalTenancy.id, + teamId: body.new_team_id, + userId: auth.user.id, + }); + + // Transfer the project + await globalPrismaClient.project.update({ + where: { + id: body.project_id, + }, + data: { + ownerTeamId: body.new_team_id, + }, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + success: "true", + }, + }; + }, +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx index bb2726c85..f42865cc6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx @@ -4,7 +4,10 @@ import { StyledLink } from "@/components/link"; import { LogoUpload } from "@/components/logo-upload"; import { FormSettingCard, SettingCard, SettingSwitch, SettingText } from "@/components/settings"; import { getPublicEnvVar } from '@/lib/env'; +import { TeamSwitcher, useUser } from "@stackframe/stack"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { ActionDialog, Alert, Button, Typography } from "@stackframe/stack-ui"; +import { useState } from "react"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; @@ -18,6 +21,38 @@ export default function PageClient() { const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); const productionModeErrors = project.useProductionModeErrors(); + const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); + const teams = user.useTeams(); + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [isTransferring, setIsTransferring] = useState(false); + + // Get current owner team + const currentOwnerTeam = teams.find(team => team.id === project.ownerTeamId) ?? throwErr(`Owner team of project ${project.id} not found in user's teams?`, { projectId: project.id, teams }); + + // Check if user has team_admin permission for the current team + const hasAdminPermissionForCurrentTeam = user.usePermission(currentOwnerTeam, "team_admin"); + + // Check if user has team_admin permission for teams + // We'll check permissions in the backend, but for UI we can check if user is in the team + const selectedTeam = teams.find(team => team.id === selectedTeamId); + + const handleTransfer = async () => { + if (!selectedTeamId || selectedTeamId === project.ownerTeamId) return; + + setIsTransferring(true); + try { + await project.transfer(user, selectedTeamId); + + // Reload the page to reflect changes + // we don't actually need this, but it's a nicer UX as it clearly indicates to the user that a "big" change was made + window.location.reload(); + } catch (error) { + console.error('Failed to transfer project:', error); + alert(`Failed to transfer project: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + setIsTransferring(false); + } + }; return ( @@ -162,6 +197,61 @@ export default function PageClient() { )} + +
+ {!hasAdminPermissionForCurrentTeam ? ( + + {`You need to be a team admin of "${currentOwnerTeam.displayName || 'the current team'}" to transfer this project.`} + + ) : ( + <> +
+ + Current owner team: {currentOwnerTeam.displayName || "Unknown"} + +
+
+
+ { + setSelectedTeamId(team.id); + }} + /> +
+ + Transfer + + } + title="Transfer Project" + okButton={{ + label: "Transfer Project", + onClick: handleTransfer + }} + cancelButton + > + + {`Are you sure you want to transfer "${project.displayName}" to ${teams.find(t => t.id === selectedTeamId)?.displayName}?`} + + + This will change the ownership of the project. Only team admins of the new team will be able to manage project settings. + + +
+ + )} +
+
+ { + it("should allow team admin to transfer project to another team they admin", async ({ expect }) => { + // Set up internal project context + backendContext.set({ projectKeys: InternalProjectKeys }); + + // Create and sign in user in internal project + const { userId } = await Auth.Otp.signIn(); + + // Create two teams where user is admin + const team1Response = await niceBackendFetch("/api/v1/teams", { + method: "POST", + accessType: "server", + body: { + display_name: "Team 1", + }, + }); + expect(team1Response.status).toBe(201); + const team1 = team1Response.body; + + const team2Response = await niceBackendFetch("/api/v1/teams", { + method: "POST", + accessType: "server", + body: { + display_name: "Team 2", + }, + }); + expect(team2Response.status).toBe(201); + const team2 = team2Response.body; + + // Add user to both teams first + await niceBackendFetch(`/api/v1/team-memberships/${team1.id}/${userId}`, { + method: "POST", + accessType: "server", + body: {}, + }); + + await niceBackendFetch(`/api/v1/team-memberships/${team2.id}/${userId}`, { + method: "POST", + accessType: "server", + body: {}, + }); + + // Grant team admin permission to user for both teams + const perm1Response = await niceBackendFetch(`/api/v1/team-permissions/${team1.id}/${userId}/team_admin`, { + method: "POST", + accessType: "server", + body: {}, + }); + expect(perm1Response.status).toBe(201); + + const perm2Response = await niceBackendFetch(`/api/v1/team-permissions/${team2.id}/${userId}/team_admin`, { + method: "POST", + accessType: "server", + body: {}, + }); + expect(perm2Response.status).toBe(201); + + // Create a project owned by team1 + const projectResponse = await niceBackendFetch("/api/v1/internal/projects", { + method: "POST", + accessType: "admin", + body: { + display_name: "Test Project", + owner_team_id: team1.id, + }, + }); + expect(projectResponse.status).toBe(201); + const project = projectResponse.body; + + // Verify project is now owned by team2 + const projectDetailsResponse1 = await niceBackendFetch(`/api/v1/internal/projects`, { + accessType: "admin", + }); + expect(projectDetailsResponse1.status).toBe(200); + expect(projectDetailsResponse1.body.items[0].owner_team_id).toBe(team1.id); + + // Transfer project to team2 + const transferResponse = await niceBackendFetch("/api/v1/internal/projects/transfer", { + method: "POST", + accessType: "server", + body: { + project_id: project.id, + new_team_id: team2.id, + }, + }); + + expect(transferResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "success": "true" }, + "headers": Headers {