mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge dev into update-oauth-docs
This commit is contained in:
commit
a0e2c4e058
@ -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.
|
||||
@ -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({
|
||||
|
||||
@ -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",
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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"
|
||||
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -13,6 +13,7 @@ const FUNCTIONAL_TAGS = [
|
||||
'API Keys',
|
||||
'CLI Authentication',
|
||||
'Contact Channels',
|
||||
'Emails',
|
||||
'Oauth', // Note: OpenAPI uses "Oauth" not "OAuth"
|
||||
'OTP',
|
||||
'Password',
|
||||
|
||||
@ -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
197
docs/templates/concepts/emails.mdx
vendored
Normal 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.
|
||||
1
docs/templates/meta.json
vendored
1
docs/templates/meta.json
vendored
@ -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",
|
||||
|
||||
6
docs/templates/sdk/index.mdx
vendored
6
docs/templates/sdk/index.mdx
vendored
@ -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: [
|
||||
|
||||
1
docs/templates/sdk/meta.json
vendored
1
docs/templates/sdk/meta.json
vendored
@ -14,6 +14,7 @@
|
||||
"types/team-permission",
|
||||
"types/team-profile",
|
||||
"types/contact-channel",
|
||||
"types/email",
|
||||
"types/api-key",
|
||||
"types/project",
|
||||
"types/connected-account",
|
||||
|
||||
72
docs/templates/sdk/objects/stack-app.mdx
vendored
72
docs/templates/sdk/objects/stack-app.mdx
vendored
@ -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
201
docs/templates/sdk/types/email.mdx
vendored
Normal 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>
|
||||
@ -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",
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user