Merge dev into update-oauth-docs

This commit is contained in:
Konsti Wohlwend 2025-08-22 04:31:32 -07:00 committed by GitHub
commit a0e2c4e058
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1207 additions and 7 deletions

View File

@ -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.
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
<TeamSwitcher
triggerClassName="w-full"
teamId={selectedTeamId}
onChange={async (team) => {
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
<Typography>{`Text with "quotes" inside`}</Typography>
```
### 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: <server key>`
- Either `X-Stack-Auth: Bearer <token>` 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.

View File

@ -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({

View File

@ -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",
},
};
},
});

View File

@ -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<string | null>(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 (
<PageLayout title="Project Settings" description="Manage your project">
@ -162,6 +197,61 @@ export default function PageClient() {
)}
</SettingCard>
<SettingCard
title="Transfer Project"
description="Transfer this project to another team"
>
<div className="flex flex-col gap-4">
{!hasAdminPermissionForCurrentTeam ? (
<Alert variant="destructive">
{`You need to be a team admin of "${currentOwnerTeam.displayName || 'the current team'}" to transfer this project.`}
</Alert>
) : (
<>
<div>
<Typography variant="secondary" className="mb-2">
Current owner team: {currentOwnerTeam.displayName || "Unknown"}
</Typography>
</div>
<div className="flex gap-2">
<div className="flex-1">
<TeamSwitcher
triggerClassName="w-full"
teamId={selectedTeamId || ""}
onChange={async (team) => {
setSelectedTeamId(team.id);
}}
/>
</div>
<ActionDialog
trigger={
<Button
variant="secondary"
disabled={!selectedTeam || isTransferring}
>
Transfer
</Button>
}
title="Transfer Project"
okButton={{
label: "Transfer Project",
onClick: handleTransfer
}}
cancelButton
>
<Typography>
{`Are you sure you want to transfer "${project.displayName}" to ${teams.find(t => t.id === selectedTeamId)?.displayName}?`}
</Typography>
<Typography className="mt-2" variant="secondary">
This will change the ownership of the project. Only team admins of the new team will be able to manage project settings.
</Typography>
</ActionDialog>
</div>
</>
)}
</div>
</SettingCard>
<SettingCard
title="Danger Zone"
description="Irreversible and destructive actions"

View File

@ -0,0 +1,391 @@
import { describe } from "vitest";
import { it } from "../../../../../../helpers";
import { Auth, InternalProjectKeys, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../../../backend-helpers";
describe("internal project transfer", () => {
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 { <some fields may have been hidden> },
}
`);
// Verify project is now owned by team2
const projectDetailsResponse2 = await niceBackendFetch(`/api/v1/internal/projects`, {
accessType: "admin",
});
expect(projectDetailsResponse2.status).toBe(200);
expect(projectDetailsResponse2.body.items[0].owner_team_id).toBe(team2.id);
});
it("should not allow non-team-admin to transfer project", async ({ expect }) => {
// Set up internal project context
backendContext.set({ projectKeys: InternalProjectKeys });
// Create admin user
const adminMailbox = await bumpEmailAddress();
const { userId: adminUserId } = await Auth.Otp.signIn();
// Create member user
const memberMailbox = await bumpEmailAddress();
const { userId: memberUserId } = await Auth.Otp.signIn();
// Switch back to admin user
backendContext.set({ mailbox: adminMailbox });
await Auth.Otp.signIn();
const team1Response = await niceBackendFetch("/api/v1/teams", {
method: "POST",
accessType: "server",
body: {
display_name: "Team 1",
},
});
const team1 = team1Response.body;
const team2Response = await niceBackendFetch("/api/v1/teams", {
method: "POST",
accessType: "server",
body: {
display_name: "Team 2",
},
});
const team2 = team2Response.body;
// Add adminUserId to both teams first
await niceBackendFetch(`/api/v1/team-memberships/${team1.id}/${adminUserId}`, {
method: "POST",
accessType: "server",
body: {},
});
await niceBackendFetch(`/api/v1/team-memberships/${team2.id}/${adminUserId}`, {
method: "POST",
accessType: "server",
body: {},
});
// Make adminUserId admin of both teams
await niceBackendFetch(`/api/v1/team-permissions/${team1.id}/${adminUserId}/team_admin`, {
method: "POST",
accessType: "server",
body: {},
});
await niceBackendFetch(`/api/v1/team-permissions/${team2.id}/${adminUserId}/team_admin`, {
method: "POST",
accessType: "server",
body: {},
});
// Add memberUserId as regular member to team1
await niceBackendFetch(`/api/v1/team-memberships/${team1.id}/${memberUserId}`, {
method: "POST",
accessType: "server",
body: {},
});
// 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,
},
});
const project = projectResponse.body;
// Switch to member user
backendContext.set({ mailbox: memberMailbox });
await Auth.Otp.signIn();
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": 401,
"body": {
"code": "TEAM_PERMISSION_REQUIRED",
"details": {
"permission_id": "team_admin",
"team_id": "<stripped UUID>",
"user_id": "<stripped UUID>",
},
"error": "User <stripped UUID> does not have permission team_admin in team <stripped UUID>.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_PERMISSION_REQUIRED",
<some fields may have been hidden>,
},
}
`);
});
it("should allow transfer to team where user is not admin but is a member", async ({ expect }) => {
// Set up internal project context
backendContext.set({ projectKeys: InternalProjectKeys });
// Create user and sign in
const { userId } = await Auth.Otp.signIn();
// Create two teams
const team1Response = await niceBackendFetch("/api/v1/teams", {
method: "POST",
accessType: "server",
body: {
display_name: "Team 1",
},
});
const team1 = team1Response.body;
const team2Response = await niceBackendFetch("/api/v1/teams", {
method: "POST",
accessType: "server",
body: {
display_name: "Team 2",
},
});
const team2 = team2Response.body;
// Add user to both teams first
await niceBackendFetch(`/api/v1/team-memberships/${team1.id}/${userId}`, {
method: "POST",
accessType: "server",
body: {},
});
// Grant team admin permission only for team1
await niceBackendFetch(`/api/v1/team-permissions/${team1.id}/${userId}/team_admin`, {
method: "POST",
accessType: "server",
body: {},
});
// Add user as regular member to team2 (not admin)
await niceBackendFetch(`/api/v1/team-memberships/${team2.id}/${userId}`, {
method: "POST",
accessType: "server",
body: {},
});
// 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,
},
});
const project = projectResponse.body;
// Should be able to transfer project to team2 even though user is not admin there
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 { <some fields may have been hidden> },
}
`);
// Verify project is now owned by team2
const projectDetailsResponse = await niceBackendFetch(`/api/v1/internal/projects`, {
accessType: "admin",
});
expect(projectDetailsResponse.status).toBe(200);
expect(projectDetailsResponse.body.items[0].owner_team_id).toBe(team2.id);
});
it("should not allow transfer to team where user is not a member", async ({ expect }) => {
// Set up internal project context
backendContext.set({ projectKeys: InternalProjectKeys });
// Create first user and sign in
const user1Mailbox = await bumpEmailAddress();
const { userId: user1Id } = await Auth.Otp.signIn();
// Create team1 with user1
const team1Response = await niceBackendFetch("/api/v1/teams", {
method: "POST",
accessType: "server",
body: {
display_name: "Team 1",
},
});
const team1 = team1Response.body;
// Create second user
const user2Mailbox = await bumpEmailAddress();
const { userId: user2Id } = await Auth.Otp.signIn();
// Create team2 with user2
const team2Response = await niceBackendFetch("/api/v1/teams", {
method: "POST",
accessType: "server",
body: {
display_name: "Team 2",
},
});
const team2 = team2Response.body;
// Sign back in as user1 (call signIn again)
backendContext.set({ mailbox: user1Mailbox });
await Auth.Otp.signIn();
// Add user1 to team1 first
await niceBackendFetch(`/api/v1/team-memberships/${team1.id}/${user1Id}`, {
method: "POST",
accessType: "server",
body: {},
});
// Grant team admin permission for team1 to user1
await niceBackendFetch(`/api/v1/team-permissions/${team1.id}/${user1Id}/team_admin`, {
method: "POST",
accessType: "server",
body: {},
});
// 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,
},
});
const project = projectResponse.body;
// Try to transfer project to team2 where user1 is not a member
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": 404,
"body": {
"code": "TEAM_MEMBERSHIP_NOT_FOUND",
"details": {
"team_id": "<stripped UUID>",
"user_id": "<stripped UUID>",
},
"error": "User <stripped UUID> is not found in team <stripped UUID>.",
},
"headers": Headers {
"x-stack-known-error": "TEAM_MEMBERSHIP_NOT_FOUND",
<some fields may have been hidden>,
},
}
`);
});
});

View File

@ -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

View File

@ -13,6 +13,7 @@ const FUNCTIONAL_TAGS = [
'API Keys',
'CLI Authentication',
'Contact Channels',
'Emails',
'Oauth', // Note: OpenAPI uses "Oauth" not "OAuth"
'OTP',
'Password',

View File

@ -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';
}

197
docs/templates/concepts/emails.mdx vendored Normal file
View File

@ -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.
<Info>
Never send marketing emails as transactional emails, as this can quickly lead to your domain being blacklisted by email spam filters.
</Info>
## 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: '<h1>Welcome!</h1><p>Thanks for joining us.</p>',
});
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<string, any>; // 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: '<p>Hello!</p>',
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: '<p>New feature available!</p>',
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.

View File

@ -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",

View File

@ -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: [

View File

@ -14,6 +14,7 @@
"types/team-permission",
"types/team-profile",
"types/contact-channel",
"types/email",
"types/api-key",
"types/project",
"types/connected-account",

View File

@ -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<ServerUser>; //$stack-link-to:#stackserverappcreateuseroptions
sendEmail(options): Promise<Result<void, KnownErrors>>; //$stack-link-to:#stackserverappsendemailoptions
getTeam(id): Promise<ServerTeam | null>; //$stack-link-to:#stackserverappgetteamid
// NEXT_LINE_PLATFORM react-like
@ -799,6 +800,77 @@ const user = await stackServerApp.createUser({
</MethodLayout>
</CollapsibleMethodSection>
<CollapsibleMethodSection method="sendEmail" signature="[options]" appType="StackServerApp" defaultOpen={false}>
<MethodLayout>
<MethodContent>
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<Result<void, KnownErrors>>`
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
</MethodContent>
<MethodAside>
<AsideSection title="Signature">
```typescript
declare function sendEmail(options: SendEmailOptions): Promise<Result<void, KnownErrors>>;
```
</AsideSection>
<AsideSection title="Examples">
<Tabs defaultValue="html-email">
<TabsList>
<TabsTrigger value="html-email">Send HTML Email</TabsTrigger>
<TabsTrigger value="template-email">Send Template Email</TabsTrigger>
</TabsList>
<TabsContent value="html-email">
```typescript
const result = await stackServerApp.sendEmail({
userIds: ['user-1', 'user-2'],
subject: 'Welcome to our platform!',
html: '<h1>Welcome!</h1><p>Thanks for joining us.</p>',
});
if (result.status === 'error') {
console.error('Failed to send email:', result.error);
}
```
</TabsContent>
<TabsContent value="template-email">
```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);
}
```
</TabsContent>
</Tabs>
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleMethodSection>
## Team Management
<CollapsibleMethodSection method="getTeam" signature="[id]" appType="StackServerApp" defaultOpen={false}>

201
docs/templates/sdk/types/email.mdx vendored Normal file
View File

@ -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
<ClickableTableOfContents code={`type SendEmailOptions = {
userIds: string[]; //$stack-link-to:#sendemailoptionsuserids
themeId?: string | null | false; //$stack-link-to:#sendemailoptionsthemeid
subject?: string; //$stack-link-to:#sendemailoptionssubject
notificationCategoryName?: string; //$stack-link-to:#sendemailoptionsnotificationcategoryname
html?: string; //$stack-link-to:#sendemailoptionshtml
templateId?: string; //$stack-link-to:#sendemailoptionstemplateid
variables?: Record<string, any>; //$stack-link-to:#sendemailoptionsvariables
};`} />
<CollapsibleTypesSection type="sendEmailOptions" property="userIds" defaultOpen={false}>
<MethodLayout>
<MethodContent>
An array of user IDs that will receive the email. All users must exist in your Stack Auth project.
</MethodContent>
<MethodAside>
<AsideSection title="Type">
```typescript
userIds: string[]
```
</AsideSection>
<AsideSection title="Example">
```typescript
{
userIds: ['user-1', 'user-2', 'user-3'],
// ... other options
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="sendEmailOptions" property="themeId" defaultOpen={false}>
<MethodLayout>
<MethodContent>
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.
</MethodContent>
<MethodAside>
<AsideSection title="Type">
```typescript
themeId?: string | null | false
```
</AsideSection>
<AsideSection title="Example">
```typescript
{
themeId: 'corporate-theme-id',
// or
themeId: null, // no theme
// or
themeId: false, // default theme
// ... other options
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="sendEmailOptions" property="subject" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Optional email subject line. If using a template, this overrides the template's default subject.
</MethodContent>
<MethodAside>
<AsideSection title="Type">
```typescript
subject?: string
```
</AsideSection>
<AsideSection title="Example">
```typescript
{
subject: 'Welcome to our platform!',
// ... other options
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="sendEmailOptions" property="notificationCategoryName" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Optional notification category name for user preferences. Users can opt in or out of specific categories through their account settings.
</MethodContent>
<MethodAside>
<AsideSection title="Type">
```typescript
notificationCategoryName?: string
```
</AsideSection>
<AsideSection title="Example">
```typescript
{
notificationCategoryName: 'product_updates',
// ... other options
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="sendEmailOptions" property="html" defaultOpen={false}>
<MethodLayout>
<MethodContent>
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`.
</MethodContent>
<MethodAside>
<AsideSection title="Type">
```typescript
html?: string
```
</AsideSection>
<AsideSection title="Example">
```typescript
{
userIds: ['user-1'],
html: '<h1>Welcome!</h1><p>Thanks for joining us.</p>',
subject: 'Welcome to our platform'
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="sendEmailOptions" property="templateId" defaultOpen={false}>
<MethodLayout>
<MethodContent>
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`.
</MethodContent>
<MethodAside>
<AsideSection title="Type">
```typescript
templateId?: string
```
</AsideSection>
<AsideSection title="Example">
```typescript
{
userIds: ['user-1'],
templateId: 'welcome-template',
variables: {
userName: 'John Doe',
activationUrl: 'https://app.com/activate/token123'
}
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>
<CollapsibleTypesSection type="sendEmailOptions" property="variables" defaultOpen={false}>
<MethodLayout>
<MethodContent>
Optional variables to substitute in the template. Only used when `templateId` is provided.
</MethodContent>
<MethodAside>
<AsideSection title="Type">
```typescript
variables?: Record<string, any>
```
</AsideSection>
<AsideSection title="Example">
```typescript
{
templateId: 'welcome-template',
variables: {
userName: 'John Doe',
activationUrl: 'https://app.com/activate/token123',
supportEmail: 'support@yourapp.com'
}
}
```
</AsideSection>
</MethodAside>
</MethodLayout>
</CollapsibleTypesSection>

View File

@ -252,6 +252,23 @@ export class StackAdminInterface extends StackServerInterface {
);
}
async transferProject(session: InternalSession, newTeamId: string): Promise<void> {
await this.sendAdminRequest(
"/internal/projects/transfer",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
project_id: this.options.projectId,
new_team_id: newTeamId,
}),
},
session,
);
}
async getMetrics(): Promise<any> {
const response = await this.sendAdminRequest(
"/internal/metrics",

View File

@ -2,7 +2,6 @@
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import {
cn,
Button,
Select,
SelectContent,
@ -14,6 +13,7 @@ import {
SelectValue,
Skeleton,
Typography,
cn,
} from "@stackframe/stack-ui";
import { PlusCircle, Settings } from "lucide-react";
import { Suspense, useMemo } from "react";
@ -77,7 +77,7 @@ function Inner<AllowNull extends boolean>(props: TeamSwitcherProps<AllowNull>) {
const navigate = app.useNavigate();
const project = app.useProject();
const rawTeams = user?.useTeams();
const selectedTeam = props.team || rawTeams?.find(team => team.id === props.teamId) || user?.selectedTeam;
const selectedTeam = props.team || rawTeams?.find(team => team.id === props.teamId);
const teams = useMemo(() => rawTeams?.sort((a, b) => b.id === selectedTeam?.id ? 1 : -1), [rawTeams, selectedTeam]);

View File

@ -8,7 +8,7 @@ import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/uti
import { pick } from "@stackframe/stack-shared/dist/utils/objects";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import { AdminSentEmail } from "../..";
import { AdminSentEmail, CurrentUser } from "../..";
import { EmailConfig, stackAppInternalsSymbol } from "../../common";
import { AdminEmailTemplate } from "../../email-templates";
import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, internalApiKeyCreateOptionsToCrud } from "../../internal-api-keys";
@ -179,6 +179,10 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
async delete() {
await app._interface.deleteProject();
},
async transfer(user: CurrentUser, newTeamId: string) {
await app._interface.transferProject(user._internalSession, newTeamId);
await onRefresh();
},
async getProductionModeErrors() {
return getProductionModeErrors(data);
},

View File

@ -2,6 +2,7 @@ import { ProductionModeError } from "@stackframe/stack-shared/dist/helpers/produ
import { AdminUserProjectsCrud, ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema";
import { CurrentUser } from "..";
import { StackAdminApp } from "../apps/interfaces/admin-app";
import { AdminProjectConfig, AdminProjectConfigUpdateOptions, ProjectConfig } from "../project-configs";
@ -26,6 +27,7 @@ export type AdminProject = {
update(this: AdminProject, update: AdminProjectUpdateOptions): Promise<void>,
delete(this: AdminProject): Promise<void>,
transfer(this: AdminProject, user: CurrentUser, newTeamId: string): Promise<void>,
getConfig(this: AdminProject): Promise<CompleteConfig>,
// NEXT_LINE_PLATFORM react-like