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