Project transfers

This commit is contained in:
Konstantin Wohlwend 2025-08-21 16:05:28 -07:00
parent 4b06bca59e
commit 301398f4cc
8 changed files with 716 additions and 4 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

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

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