mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Project transfers
This commit is contained in:
parent
4b06bca59e
commit
301398f4cc
@ -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.
|
||||
@ -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>,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@ -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