Skip to content

Commit 301398f

Browse files
committed
Project transfers
1 parent 4b06bca commit 301398f

8 files changed

Lines changed: 716 additions & 4 deletions

File tree

.claude/CLAUDE-KNOWLEDGE.md

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,122 @@ A: ESLint may remove "unused" imports. Always verify your changes after auto-fix
122122
A: Missing newline at end of file. ESLint requires files to end with a newline character.
123123

124124
### Q: How do you handle TypeScript errors about missing exports?
125-
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.
125+
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.
126+
127+
## Project Transfer Implementation
128+
129+
### Q: How do I add a new API endpoint to the internal project?
130+
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.
131+
132+
### Q: How do team permissions work in Stack Auth?
133+
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.
134+
135+
### Q: How do I check team permissions in the backend?
136+
A: Use `ensureUserTeamPermissionExists` from `/apps/backend/src/lib/request-checks.tsx`. Example:
137+
```typescript
138+
await ensureUserTeamPermissionExists(prisma, {
139+
tenancy: internalTenancy,
140+
teamId: teamId,
141+
userId: userId,
142+
permissionId: "team_admin",
143+
errorType: "required",
144+
recursive: true,
145+
});
146+
```
147+
148+
### Q: How do I add new functionality to the admin interface?
149+
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.
150+
151+
### Q: How do I use TeamSwitcher component in the dashboard?
152+
A: Import `TeamSwitcher` from `@stackframe/stack` and use it like:
153+
```typescript
154+
<TeamSwitcher
155+
triggerClassName="w-full"
156+
teamId={selectedTeamId}
157+
onChange={async (team) => {
158+
setSelectedTeamId(team.id);
159+
}}
160+
/>
161+
```
162+
163+
### Q: How do I write E2E tests for backend endpoints?
164+
A: Import `it` from helpers (not vitest), and set up the project context inside each test:
165+
```typescript
166+
import { describe } from "vitest";
167+
import { it } from "../../../../../../helpers";
168+
import { Auth, Project, backendContext, niceBackendFetch, InternalProjectKeys } from "../../../../../backend-helpers";
169+
170+
it("test name", async ({ expect }) => {
171+
backendContext.set({ projectKeys: InternalProjectKeys });
172+
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
173+
// test logic
174+
});
175+
```
176+
177+
### Q: Where is project ownership stored in the database?
178+
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.
179+
180+
### Q: How do I make authenticated API calls from dashboard server actions?
181+
A: Get the session cookie and include it in the request headers:
182+
```typescript
183+
const cookieStore = await cookies();
184+
const sessionCookie = cookieStore.get("stack-refresh-internal");
185+
const response = await fetch(url, {
186+
headers: {
187+
'X-Stack-Access-Type': 'server',
188+
'X-Stack-Project-Id': 'internal',
189+
'X-Stack-Secret-Server-Key': getEnvVariable('STACK_SECRET_SERVER_KEY'),
190+
...(sessionCookie ? { 'Cookie': `${sessionCookie.name}=${sessionCookie.value}` } : {})
191+
}
192+
});
193+
```
194+
195+
### Q: What's the difference between ensureTeamMembershipExists and ensureUserTeamPermissionExists?
196+
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.
197+
198+
### Q: How do I handle errors in the backend API?
199+
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.
200+
201+
### Q: What's the pattern for TypeScript schema validation in API routes?
202+
A: Use yup schemas from `@stackframe/stack-shared/dist/schema-fields`. Don't use regular yup imports. Example:
203+
```typescript
204+
import { yupObject, yupString, yupNumber } from "@stackframe/stack-shared/dist/schema-fields";
205+
```
206+
207+
### Q: How are teams and projects related in Stack Auth?
208+
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.
209+
210+
### Q: How do I properly escape quotes in React components to avoid lint errors?
211+
A: Use template literals with backticks instead of quotes in JSX text content:
212+
```typescript
213+
<Typography>{`Text with "quotes" inside`}</Typography>
214+
```
215+
216+
### Q: What auth headers are needed for internal API calls?
217+
A: Internal API calls need:
218+
- `X-Stack-Access-Type: 'server'`
219+
- `X-Stack-Project-Id: 'internal'`
220+
- `X-Stack-Secret-Server-Key: <server key>`
221+
- Either `X-Stack-Auth: Bearer <token>` or a session cookie
222+
223+
### Q: How do I reload the page after a successful action in the dashboard?
224+
A: Use `window.location.reload()` after the action completes. This ensures the UI reflects the latest state from the server.
225+
226+
### Q: What's the file structure for API routes in the backend?
227+
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.).
228+
229+
### Q: How do I get all teams a user is a member of in the dashboard?
230+
A: Use `user.useTeams()` where `user` is from `useUser({ or: 'redirect', projectIdMustMatch: "internal" })`.
231+
232+
### Q: What's the difference between client and server access types?
233+
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.
234+
235+
### Q: How to avoid TypeScript "unnecessary conditional" errors when checking auth.user?
236+
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.
237+
238+
### Q: What to do when TypeScript can't find module '@stackframe/stack' declarations?
239+
A: This happens when packages haven't been built yet. Run these commands in order:
240+
```bash
241+
pnpm clean && pnpm i && pnpm codegen && pnpm build:packages
242+
```
243+
Then restart the dev server. This rebuilds all packages and generates the necessary TypeScript declarations.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks";
2+
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
3+
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
4+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
5+
import { KnownErrors } from "@stackframe/stack-shared";
6+
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
7+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
8+
9+
export const POST = createSmartRouteHandler({
10+
metadata: {
11+
hidden: true,
12+
},
13+
request: yupObject({
14+
auth: yupObject({
15+
project: yupObject({
16+
id: yupString().oneOf(["internal"]).defined(),
17+
}).defined(),
18+
user: yupObject({
19+
id: yupString().defined(),
20+
}).defined(),
21+
}).defined(),
22+
body: yupObject({
23+
project_id: yupString().defined(),
24+
new_team_id: yupString().defined(),
25+
}).defined(),
26+
}),
27+
response: yupObject({
28+
statusCode: yupNumber().oneOf([200]).defined(),
29+
bodyType: yupString().oneOf(["json"]).defined(),
30+
body: yupObject({
31+
success: yupString().oneOf(["true"]).defined(),
32+
}).defined(),
33+
}),
34+
handler: async (req) => {
35+
const { auth, body } = req;
36+
37+
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
38+
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
39+
40+
// Get the project to transfer
41+
const projectToTransfer = await globalPrismaClient.project.findUnique({
42+
where: {
43+
id: body.project_id,
44+
},
45+
});
46+
47+
if (!projectToTransfer) {
48+
throw new KnownErrors.ProjectNotFound(body.project_id);
49+
}
50+
51+
if (!projectToTransfer.ownerTeamId) {
52+
throw new StatusError(400, "Project must have an owner team to be transferred");
53+
}
54+
55+
// Check if user is a team admin of the current owner team
56+
await ensureUserTeamPermissionExists(internalPrisma, {
57+
tenancy: internalTenancy,
58+
teamId: projectToTransfer.ownerTeamId,
59+
userId: auth.user.id,
60+
permissionId: "team_admin",
61+
errorType: "required",
62+
recursive: true,
63+
});
64+
65+
// Check if user is a member of the new team (doesn't need to be admin)
66+
await ensureTeamMembershipExists(internalPrisma, {
67+
tenancyId: internalTenancy.id,
68+
teamId: body.new_team_id,
69+
userId: auth.user.id,
70+
});
71+
72+
// Transfer the project
73+
await globalPrismaClient.project.update({
74+
where: {
75+
id: body.project_id,
76+
},
77+
data: {
78+
ownerTeamId: body.new_team_id,
79+
},
80+
});
81+
82+
return {
83+
statusCode: 200,
84+
bodyType: "json",
85+
body: {
86+
success: "true",
87+
},
88+
};
89+
},
90+
});

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { StyledLink } from "@/components/link";
44
import { LogoUpload } from "@/components/logo-upload";
55
import { FormSettingCard, SettingCard, SettingSwitch, SettingText } from "@/components/settings";
66
import { getPublicEnvVar } from '@/lib/env';
7+
import { TeamSwitcher, useUser } from "@stackframe/stack";
8+
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
79
import { ActionDialog, Alert, Button, Typography } from "@stackframe/stack-ui";
10+
import { useState } from "react";
811
import * as yup from "yup";
912
import { PageLayout } from "../page-layout";
1013
import { useAdminApp } from "../use-admin-app";
@@ -18,6 +21,38 @@ export default function PageClient() {
1821
const stackAdminApp = useAdminApp();
1922
const project = stackAdminApp.useProject();
2023
const productionModeErrors = project.useProductionModeErrors();
24+
const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" });
25+
const teams = user.useTeams();
26+
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
27+
const [isTransferring, setIsTransferring] = useState(false);
28+
29+
// Get current owner team
30+
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 });
31+
32+
// Check if user has team_admin permission for the current team
33+
const hasAdminPermissionForCurrentTeam = user.usePermission(currentOwnerTeam, "team_admin");
34+
35+
// Check if user has team_admin permission for teams
36+
// We'll check permissions in the backend, but for UI we can check if user is in the team
37+
const selectedTeam = teams.find(team => team.id === selectedTeamId);
38+
39+
const handleTransfer = async () => {
40+
if (!selectedTeamId || selectedTeamId === project.ownerTeamId) return;
41+
42+
setIsTransferring(true);
43+
try {
44+
await project.transfer(user, selectedTeamId);
45+
46+
// Reload the page to reflect changes
47+
// 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
48+
window.location.reload();
49+
} catch (error) {
50+
console.error('Failed to transfer project:', error);
51+
alert(`Failed to transfer project: ${error instanceof Error ? error.message : 'Unknown error'}`);
52+
} finally {
53+
setIsTransferring(false);
54+
}
55+
};
2156

2257
return (
2358
<PageLayout title="Project Settings" description="Manage your project">
@@ -162,6 +197,61 @@ export default function PageClient() {
162197
)}
163198
</SettingCard>
164199

200+
<SettingCard
201+
title="Transfer Project"
202+
description="Transfer this project to another team"
203+
>
204+
<div className="flex flex-col gap-4">
205+
{!hasAdminPermissionForCurrentTeam ? (
206+
<Alert variant="destructive">
207+
{`You need to be a team admin of "${currentOwnerTeam.displayName || 'the current team'}" to transfer this project.`}
208+
</Alert>
209+
) : (
210+
<>
211+
<div>
212+
<Typography variant="secondary" className="mb-2">
213+
Current owner team: {currentOwnerTeam.displayName || "Unknown"}
214+
</Typography>
215+
</div>
216+
<div className="flex gap-2">
217+
<div className="flex-1">
218+
<TeamSwitcher
219+
triggerClassName="w-full"
220+
teamId={selectedTeamId || ""}
221+
onChange={async (team) => {
222+
setSelectedTeamId(team.id);
223+
}}
224+
/>
225+
</div>
226+
<ActionDialog
227+
trigger={
228+
<Button
229+
variant="secondary"
230+
disabled={!selectedTeam || isTransferring}
231+
>
232+
Transfer
233+
</Button>
234+
}
235+
title="Transfer Project"
236+
okButton={{
237+
label: "Transfer Project",
238+
onClick: handleTransfer
239+
}}
240+
cancelButton
241+
>
242+
<Typography>
243+
{`Are you sure you want to transfer "${project.displayName}" to ${teams.find(t => t.id === selectedTeamId)?.displayName}?`}
244+
</Typography>
245+
<Typography className="mt-2" variant="secondary">
246+
This will change the ownership of the project. Only team admins of the new team will be able to manage project settings.
247+
</Typography>
248+
</ActionDialog>
249+
</div>
250+
</>
251+
)}
252+
</div>
253+
</SettingCard>
254+
165255
<SettingCard
166256
title="Danger Zone"
167257
description="Irreversible and destructive actions"

0 commit comments

Comments
 (0)