From ddaa36705ec9f4c267913466c9af495cb0e54caa Mon Sep 17 00:00:00 2001 From: Stan Wohlwend Date: Wed, 24 Jul 2024 12:03:14 -0700 Subject: [PATCH 01/19] Support moduleResolution: "node" --- examples/e-commerce/tsconfig.json | 2 +- packages/stack/package.json | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/e-commerce/tsconfig.json b/examples/e-commerce/tsconfig.json index 7b28589304..9bb90ae689 100644 --- a/examples/e-commerce/tsconfig.json +++ b/examples/e-commerce/tsconfig.json @@ -7,7 +7,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "bundler", + "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", diff --git a/packages/stack/package.json b/packages/stack/package.json index cee623a9ba..01d3fcf1fd 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -2,6 +2,8 @@ "name": "@stackframe/stack", "version": "2.5.8", "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", From d5529d0568cabe566572278c68068bb895c1174b Mon Sep 17 00:00:00 2001 From: Stan Wohlwend Date: Wed, 24 Jul 2024 12:04:53 -0700 Subject: [PATCH 02/19] Remove deprecated TeamSwitcher, use SelectedTeamSwitcher instead --- docs/fern/docs/pages/getting-started/components.mdx | 4 ++-- packages/stack/src/index.tsx | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/fern/docs/pages/getting-started/components.mdx b/docs/fern/docs/pages/getting-started/components.mdx index 7e1a03611e..9c1a8e6f00 100644 --- a/docs/fern/docs/pages/getting-started/components.mdx +++ b/docs/fern/docs/pages/getting-started/components.mdx @@ -44,8 +44,8 @@ export default function Page() { All of Stack's components are modular and built from smaller primitives. For example, the `` component is composed of the following: - An ``, which itself is composed of multiple `` components -- A ``, which has a text field and calls `useStackApp().signInWithMagicLink()` -- A ``, which has two text fields and calls `useStackApp().signInWithCredential()` +- A ``, which has a text field and calls `useStackApp().signInWithMagicLink()` +- A ``, which has two text fields and calls `useStackApp().signInWithCredential()` You can use these components individually to build a custom sign-in component. diff --git a/packages/stack/src/index.tsx b/packages/stack/src/index.tsx index e56f7cccfb..f9cc9a4561 100644 --- a/packages/stack/src/index.tsx +++ b/packages/stack/src/index.tsx @@ -21,8 +21,4 @@ export { OAuthButtonGroup } from "./components/oauth-button-group"; export { SelectedTeamSwitcher, - /** - * @deprecated This was renamed to `SelectedTeamSwitcher`. - */ - SelectedTeamSwitcher as TeamSwitcher, } from "./components/selected-team-switcher"; From 036d88699046e0d0b901e139bd7545789406ab34 Mon Sep 17 00:00:00 2001 From: Stan Wohlwend Date: Wed, 24 Jul 2024 12:07:58 -0700 Subject: [PATCH 03/19] Rename CredentialSignInForm -> CredentialSignIn in code --- packages/stack/src/components-page/auth-page.tsx | 14 +++++++------- ...ial-sign-in-form.tsx => credential-sign-in.tsx} | 2 +- ...ial-sign-up-form.tsx => credential-sign-up.tsx} | 2 +- ...ink-sign-in-form.tsx => magic-link-sign-in.tsx} | 2 +- packages/stack/src/index.tsx | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) rename packages/stack/src/components/{credential-sign-in-form.tsx => credential-sign-in.tsx} (98%) rename packages/stack/src/components/{credential-sign-up-form.tsx => credential-sign-up.tsx} (98%) rename packages/stack/src/components/{magic-link-sign-in-form.tsx => magic-link-sign-in.tsx} (97%) diff --git a/packages/stack/src/components-page/auth-page.tsx b/packages/stack/src/components-page/auth-page.tsx index 01757d58f8..08f8825b65 100644 --- a/packages/stack/src/components-page/auth-page.tsx +++ b/packages/stack/src/components-page/auth-page.tsx @@ -1,13 +1,13 @@ 'use client'; -import { CredentialSignInForm } from '../components/credential-sign-in-form'; +import { CredentialSignIn } from '../components/credential-sign-in'; import { SeparatorWithText } from '../components/elements/separator-with-text'; import { OAuthButtonGroup } from '../components/oauth-button-group'; import { MaybeFullPage } from '../components/elements/maybe-full-page'; import { useUser, useStackApp } from '..'; import { PredefinedMessageCard } from '../components/message-cards/predefined-message-card'; -import { MagicLinkSignInForm } from '../components/magic-link-sign-in-form'; -import { CredentialSignUpForm } from '../components/credential-sign-up-form'; +import { MagicLinkSignIn } from '../components/magic-link-sign-in'; +import { CredentialSignUp } from '../components/credential-sign-up'; import { StyledLink, Tabs, TabsContent, TabsList, TabsTrigger, Typography } from '@stackframe/stack-ui'; import { Project } from '../lib/stack-app'; @@ -71,16 +71,16 @@ export function AuthPage({ Password - + - {type === 'sign-up' ? : } + {type === 'sign-up' ? : } ) : project.config.credentialEnabled ? ( - type === 'sign-up' ? : + type === 'sign-up' ? : ) : project.config.magicLinkEnabled ? ( - + ) : null} diff --git a/packages/stack/src/components/credential-sign-in-form.tsx b/packages/stack/src/components/credential-sign-in.tsx similarity index 98% rename from packages/stack/src/components/credential-sign-in-form.tsx rename to packages/stack/src/components/credential-sign-in.tsx index a87fdc6799..79d65764e7 100644 --- a/packages/stack/src/components/credential-sign-in-form.tsx +++ b/packages/stack/src/components/credential-sign-in.tsx @@ -15,7 +15,7 @@ const schema = yupObject({ password: yupString().required('Please enter your password') }); -export function CredentialSignInForm() { +export function CredentialSignIn() { const { register, handleSubmit, setError, formState: { errors } } = useForm({ resolver: yupResolver(schema) }); diff --git a/packages/stack/src/components/credential-sign-up-form.tsx b/packages/stack/src/components/credential-sign-up.tsx similarity index 98% rename from packages/stack/src/components/credential-sign-up-form.tsx rename to packages/stack/src/components/credential-sign-up.tsx index b1252af31c..959bfb464a 100644 --- a/packages/stack/src/components/credential-sign-up-form.tsx +++ b/packages/stack/src/components/credential-sign-up.tsx @@ -27,7 +27,7 @@ const schema = yupObject({ passwordRepeat: yupString().nullable().oneOf([yup.ref('password'), "", null], 'Passwords do not match').required('Please repeat your password') }); -export function CredentialSignUpForm() { +export function CredentialSignUp() { const { register, handleSubmit, setError, formState: { errors }, clearErrors } = useForm({ resolver: yupResolver(schema) }); diff --git a/packages/stack/src/components/magic-link-sign-in-form.tsx b/packages/stack/src/components/magic-link-sign-in.tsx similarity index 97% rename from packages/stack/src/components/magic-link-sign-in-form.tsx rename to packages/stack/src/components/magic-link-sign-in.tsx index f7ccd4b074..e8a0f93ee9 100644 --- a/packages/stack/src/components/magic-link-sign-in-form.tsx +++ b/packages/stack/src/components/magic-link-sign-in.tsx @@ -14,7 +14,7 @@ const schema = yupObject({ email: yupString().email('Please enter a valid email').required('Please enter your email') }); -export function MagicLinkSignInForm() { +export function MagicLinkSignIn() { const { register, handleSubmit, setError, formState: { errors }, clearErrors } = useForm({ resolver: yupResolver(schema) }); diff --git a/packages/stack/src/index.tsx b/packages/stack/src/index.tsx index f9cc9a4561..b3d2891720 100644 --- a/packages/stack/src/index.tsx +++ b/packages/stack/src/index.tsx @@ -13,9 +13,9 @@ export { MessageCard } from "./components/message-cards/message-card"; export { UserButton } from "./components/user-button"; export { AccountSettings } from "./components-page/account-settings"; export { AuthPage } from "./components-page/auth-page"; -export { CredentialSignInForm as CredentialSignIn } from "./components/credential-sign-in-form"; -export { CredentialSignUpForm as CredentialSignUp } from "./components/credential-sign-up-form"; -export { MagicLinkSignInForm as MagicLinkSignIn } from "./components/magic-link-sign-in-form"; +export { CredentialSignIn as CredentialSignIn } from "./components/credential-sign-in"; +export { CredentialSignUp as CredentialSignUp } from "./components/credential-sign-up"; +export { MagicLinkSignIn as MagicLinkSignIn } from "./components/magic-link-sign-in"; export { OAuthButton } from "./components/oauth-button"; export { OAuthButtonGroup } from "./components/oauth-button-group"; From 9fda0f37f9d7cb7c21e6041e3234549a512eca38 Mon Sep 17 00:00:00 2001 From: Stan Wohlwend Date: Wed, 24 Jul 2024 12:31:36 -0700 Subject: [PATCH 04/19] Improve KnownError messages --- packages/stack-shared/src/known-errors.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index 3266ff68f5..27cf499de2 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -285,7 +285,11 @@ const AccessTypeWithoutProjectId = createKnownErrorConstructor( "ACCESS_TYPE_WITHOUT_PROJECT_ID", (accessType: "client" | "server" | "admin") => [ 400, - `The x-stack-access-type header was '${accessType}', but the x-stack-project-id header was not provided.`, + deindent` + The x-stack-access-type header was '${accessType}', but the x-stack-project-id header was not provided. + + For more information, see the docs on REST API authentication: https://docs.stack-auth.com/rest-api/auth#authentication + `, { request_type: accessType, }, @@ -298,7 +302,11 @@ const AccessTypeRequired = createKnownErrorConstructor( "ACCESS_TYPE_REQUIRED", () => [ 400, - `You must specify an access level for this Stack project. Make sure project API keys are provided (eg. x-stack-publishable-client-key) and you set the x-stack-access-type header to 'client', 'server', or 'admin'.`, + deindent` + You must specify an access level for this Stack project. Make sure project API keys are provided (eg. x-stack-publishable-client-key) and you set the x-stack-access-type header to 'client', 'server', or 'admin'. + + For more information, see the docs on REST API authentication: https://docs.stack-auth.com/rest-api/auth#authentication + `, ] as const, () => [] as const, ); From 02513363408b65c6deda723a5da1999fc3e65d58 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Wed, 24 Jul 2024 12:37:22 -0700 Subject: [PATCH 05/19] Fix connected accounts (#148) * fixed endpoint * fixed account linking * fixed get access token * fixed import bug --- .../auth/oauth/authorize/[provider]/route.tsx | 2 + .../[provider]/access-token/crud.tsx | 81 +++++++++++++++++++ .../[provider]/access-token/route.tsx | 4 +- .../v1/auth/access-token/[provider]/route.tsx | 2 +- .../src/interface/clientInterface.ts | 14 ++-- .../stack-shared/src/interface/crud/oauth.ts | 12 +-- packages/stack/src/lib/stack-app.ts | 5 +- 7 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider]/access-token/crud.tsx diff --git a/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx index 1c79486da4..4e064cfb83 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/authorize/[provider]/route.tsx @@ -32,6 +32,7 @@ export const GET = createSmartRouteHandler({ token: yupString().default(""), provider_scope: yupString().optional(), error_redirect_url: yupString().optional(), + after_callback_redirect_url: yupString().optional(), // oauth parameters client_id: yupString().required(), @@ -109,6 +110,7 @@ export const GET = createSmartRouteHandler({ projectUserId: projectUserId, providerScope: query.provider_scope, errorRedirectUrl: query.error_redirect_url, + afterCallbackRedirectUrl: query.after_callback_redirect_url, } satisfies yup.InferType, expiresAt: new Date(Date.now() + 1000 * 60 * outerOAuthFlowExpirationInMinutes), }, diff --git a/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider]/access-token/crud.tsx b/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider]/access-token/crud.tsx new file mode 100644 index 0000000000..54e52dd8e1 --- /dev/null +++ b/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider]/access-token/crud.tsx @@ -0,0 +1,81 @@ +import { getProvider } from "@/oauth"; +import { prismaClient } from "@/prisma-client"; +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { providerAccessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth"; +import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings"; + + +export const providerAccessTokenCrudHandlers = createCrudHandlers(providerAccessTokenCrud, { + paramsSchema: yupObject({ + provider: yupString().required(), + }), + async onCreate({ auth, data, params }) { + if (!auth.user) throw new KnownErrors.UserNotFound(); + const provider = auth.project.config.oauth_providers.find((p) => p.id === params.provider); + if (!provider || !provider.enabled) { + throw new KnownErrors.OAuthProviderNotFoundOrNotEnabled(); + } + + if (provider.type === 'shared') { + throw new KnownErrors.OAuthAccessTokenNotAvailableWithSharedOAuthKeys(); + } + + if (!auth.user.oauth_providers.map(x => x.id).includes(params.provider)) { + throw new KnownErrors.OAuthConnectionNotConnectedToUser(); + } + + const tokens = await prismaClient.oAuthToken.findMany({ + where: { + projectId: auth.project.id, + oAuthProviderConfigId: params.provider, + projectUserOAuthAccount: { + projectUserId: auth.user.id, + } + }, + }); + + const filteredTokens = tokens.filter((t) => { + return extractScopes(data.scope || "").every((scope) => t.scopes.includes(scope)); + }); + + if (filteredTokens.length === 0) { + throw new KnownErrors.OAuthConnectionDoesNotHaveRequiredScope(); + } + + const tokenSet = await (await getProvider(provider)).getAccessToken({ + refreshToken: filteredTokens[0].refreshToken, + scope: data.scope, + }); + + if (!tokenSet.access_token) { + throw new StackAssertionError("No access token returned"); + } + + if (tokenSet.refresh_token) { + // remove the old token, add the new token to the DB + await prismaClient.oAuthToken.deleteMany({ + where: { + refreshToken: filteredTokens[0].refreshToken, + }, + }); + await prismaClient.oAuthToken.create({ + data: { + projectId: auth.project.id, + oAuthProviderConfigId: provider.id, + refreshToken: tokenSet.refresh_token, + providerAccountId: filteredTokens[0].providerAccountId, + scopes: filteredTokens[0].scopes, + } + }); + } + + return { + access_token: tokenSet.access_token, + }; + }, +}); + + diff --git a/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider]/access-token/route.tsx b/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider]/access-token/route.tsx index b3d0b374a9..278ae043e8 100644 --- a/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider]/access-token/route.tsx +++ b/apps/backend/src/app/api/v1/auth/oauth/connected-accounts/[provider]/access-token/route.tsx @@ -1,3 +1,3 @@ -import { NextResponse } from "next/server"; +import { providerAccessTokenCrudHandlers } from "./crud"; -export const GET = () => NextResponse.json("TODO"); +export const POST = providerAccessTokenCrudHandlers.createHandler; \ No newline at end of file diff --git a/apps/dashboard/src/app/api/v1/auth/access-token/[provider]/route.tsx b/apps/dashboard/src/app/api/v1/auth/access-token/[provider]/route.tsx index d7e2881281..ed6d667f6d 100644 --- a/apps/dashboard/src/app/api/v1/auth/access-token/[provider]/route.tsx +++ b/apps/dashboard/src/app/api/v1/auth/access-token/[provider]/route.tsx @@ -3,7 +3,7 @@ import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { sharedProviders } from "@stackframe/stack-shared/dist/interface/clientInterface"; -import { accessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud/oauth"; +import { accessTokenCrud } from "@stackframe/stack-shared/dist/interface/crud-deprecated/oauth"; import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings"; diff --git a/packages/stack-shared/src/interface/clientInterface.ts b/packages/stack-shared/src/interface/clientInterface.ts index 0313fef65b..691d1914eb 100644 --- a/packages/stack-shared/src/interface/clientInterface.ts +++ b/packages/stack-shared/src/interface/clientInterface.ts @@ -9,6 +9,7 @@ import { globalVar } from '../utils/globals'; import { ReadonlyJson } from '../utils/json'; import { Result } from "../utils/results"; import { CurrentUserCrud } from './crud/current-user'; +import { ProviderAccessTokenCrud } from './crud/oauth'; import { InternalProjectsCrud, ProjectsCrud } from './crud/projects'; import { TeamPermissionsCrud } from './crud/team-permissions'; import { TeamsCrud } from './crud/teams'; @@ -610,7 +611,7 @@ export class StackClientInterface { url.searchParams.set("error_redirect_url", options.errorRedirectUrl); if (options.afterCallbackRedirectUrl) { - url.searchParams.set("after_callback_redirect_rrl", options.afterCallbackRedirectUrl); + url.searchParams.set("after_callback_redirect_url", options.afterCallbackRedirectUrl); } if (options.type === "link") { @@ -802,13 +803,13 @@ export class StackClientInterface { return json; } - async getAccessToken( + async createProviderAccessToken( provider: string, scope: string, session: InternalSession, - ): Promise<{ accessToken: string }> { + ): Promise { const response = await this.sendClientRequest( - `/auth/oauth/connected-account/${provider}/access-token`, + `/auth/oauth/connected-accounts/${provider}/access-token`, { method: "POST", headers: { @@ -818,10 +819,7 @@ export class StackClientInterface { }, session, ); - const json = await response.json(); - return { - accessToken: json.accessToken, - }; + return await response.json(); } async createTeamForCurrentUser( diff --git a/packages/stack-shared/src/interface/crud/oauth.ts b/packages/stack-shared/src/interface/crud/oauth.ts index b0fb2ebbd3..c1bd3f5927 100644 --- a/packages/stack-shared/src/interface/crud/oauth.ts +++ b/packages/stack-shared/src/interface/crud/oauth.ts @@ -1,16 +1,16 @@ import { CrudTypeOf, createCrud } from "../../crud"; import { yupObject, yupString } from "../../schema-fields"; -export const accessTokenReadSchema = yupObject({ +export const providerAccessTokenReadSchema = yupObject({ access_token: yupString().required(), }).required(); -export const accessTokenCreateSchema = yupObject({ +export const providerAccessTokenCreateSchema = yupObject({ scope: yupString().optional(), }).required(); -export const accessTokenCrud = createCrud({ - clientReadSchema: accessTokenReadSchema, - clientCreateSchema: accessTokenCreateSchema, +export const providerAccessTokenCrud = createCrud({ + clientReadSchema: providerAccessTokenReadSchema, + clientCreateSchema: providerAccessTokenCreateSchema, }); -export type AccessTokenCrud = CrudTypeOf; +export type ProviderAccessTokenCrud = CrudTypeOf; diff --git a/packages/stack/src/lib/stack-app.ts b/packages/stack/src/lib/stack-app.ts index e072aad154..e45a0124f0 100644 --- a/packages/stack/src/lib/stack-app.ts +++ b/packages/stack/src/lib/stack-app.ts @@ -291,7 +291,10 @@ class _StackClientAppImpl( async (session, [accountId, scope]) => { try { - return await this._interface.getAccessToken(accountId, scope || "", session); + const result = await this._interface.createProviderAccessToken(accountId, scope || "", session); + return { + accessToken: result.access_token, + }; } catch (err) { if (!(err instanceof KnownErrors.OAuthConnectionDoesNotHaveRequiredScope || err instanceof KnownErrors.OAuthConnectionNotConnectedToUser)) { throw err; From 4bd2ce6b1694b2fe25f9bd29d6d58f82ac37c3bc Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Wed, 24 Jul 2024 16:53:31 -0700 Subject: [PATCH 06/19] Client team API (#149) * added transactions * added client team update and delete * added client side remove member * allow user remove them selves from team * fixed bug, fixed tests * added client update test, fixed bugs * added tests for team delete * added more tests, fixed bugs --- .../src/app/api/v1/projects/current/crud.tsx | 2 +- .../src/app/api/v1/team-memberships/crud.tsx | 30 +++- .../v1/team-permission-definitions/crud.tsx | 37 ++-- .../src/app/api/v1/team-permissions/crud.tsx | 47 ++--- apps/backend/src/app/api/v1/teams/crud.tsx | 56 ++++-- apps/backend/src/lib/permissions.tsx | 165 +++++++++++------- .../lib/{db-checks.tsx => request-checks.tsx} | 30 ++++ .../backend/endpoints/api/v1/index.test.ts | 2 +- .../api/v1/internal/api-keys.test.ts | 2 +- .../api/v1/internal/projects.test.ts | 2 +- .../backend/endpoints/api/v1/projects.test.ts | 2 +- .../endpoints/api/v1/team-memberships.test.ts | 81 ++++++++- .../backend/endpoints/api/v1/teams.test.ts | 114 +++++++++++- .../backend/endpoints/api/v1/users.test.ts | 4 +- .../src/interface/crud/team-memberships.ts | 15 +- .../stack-shared/src/interface/crud/teams.ts | 6 +- packages/stack-shared/src/known-errors.tsx | 16 ++ 17 files changed, 474 insertions(+), 137 deletions(-) rename apps/backend/src/lib/{db-checks.tsx => request-checks.tsx} (62%) diff --git a/apps/backend/src/app/api/v1/projects/current/crud.tsx b/apps/backend/src/app/api/v1/projects/current/crud.tsx index 65ea676195..a5f95f61f6 100644 --- a/apps/backend/src/app/api/v1/projects/current/crud.tsx +++ b/apps/backend/src/app/api/v1/projects/current/crud.tsx @@ -30,7 +30,7 @@ export const projectsCrudHandlers = createCrudHandlers(projectsCrud, { }, ] as const; - const permissions = await listTeamPermissionDefinitions(oldProject); + const permissions = await listTeamPermissionDefinitions(tx, oldProject); for (const param of dbParams) { diff --git a/apps/backend/src/app/api/v1/team-memberships/crud.tsx b/apps/backend/src/app/api/v1/team-memberships/crud.tsx index d9f711bcce..f7dc36c34b 100644 --- a/apps/backend/src/app/api/v1/team-memberships/crud.tsx +++ b/apps/backend/src/app/api/v1/team-memberships/crud.tsx @@ -1,10 +1,11 @@ -import { ensureTeamExist, ensureTeamMembershipDoesNotExist } from "@/lib/db-checks"; +import { ensureTeamExist, ensureTeamMembershipDoesNotExist, ensureUserHasTeamPermission } from "@/lib/request-checks"; import { isTeamSystemPermission, teamSystemPermissionStringToDBType } from "@/lib/permissions"; import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { getIdFromUserIdOrMe } from "@/route-handlers/utils"; import { teamMembershipsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-memberships"; import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; export const teamMembershipsCrudHandlers = createCrudHandlers(teamMembershipsCrud, { @@ -59,14 +60,29 @@ export const teamMembershipsCrudHandlers = createCrudHandlers(teamMembershipsCru return {}; }, onDelete: async ({ auth, params }) => { - await prismaClient.teamMember.delete({ - where: { - projectId_projectUserId_teamId: { - projectId: auth.project.id, - projectUserId: params.user_id, + await prismaClient.$transaction(async (tx) => { + const userId = getIdFromUserIdOrMe(params.user_id, auth.user); + + // Users are always allowed to remove themselves from a team + // Only users with the $remove_members permission can remove other users + if (auth.type === 'client' && userId !== auth.user?.id) { + await ensureUserHasTeamPermission(tx, { + project: auth.project, teamId: params.team_id, + userId: auth.user?.id ?? throwErr('auth.user is null'), + permissionId: "$remove_members", + }); + } + + await tx.teamMember.delete({ + where: { + projectId_projectUserId_teamId: { + projectId: auth.project.id, + projectUserId: params.user_id, + teamId: params.team_id, + }, }, - }, + }); }); }, }); diff --git a/apps/backend/src/app/api/v1/team-permission-definitions/crud.tsx b/apps/backend/src/app/api/v1/team-permission-definitions/crud.tsx index 178a1d979e..2f70dc7fd7 100644 --- a/apps/backend/src/app/api/v1/team-permission-definitions/crud.tsx +++ b/apps/backend/src/app/api/v1/team-permission-definitions/crud.tsx @@ -1,4 +1,5 @@ import { createTeamPermissionDefinition, deleteTeamPermissionDefinition, listTeamPermissionDefinitions, updateTeamPermissionDefinitions } from "@/lib/permissions"; +import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { teamPermissionDefinitionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions'; import { teamPermissionDefinitionIdSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -8,28 +9,36 @@ export const teamPermissionDefinitionsCrudHandlers = createCrudHandlers(teamPerm permission_id: teamPermissionDefinitionIdSchema.required(), }), async onCreate({ auth, data }) { - return await createTeamPermissionDefinition({ - project: auth.project, - data, + return await prismaClient.$transaction(async (tx) => { + return await createTeamPermissionDefinition(tx, { + project: auth.project, + data, + }); }); }, async onUpdate({ auth, data, params }) { - return await updateTeamPermissionDefinitions({ - project: auth.project, - permissionId: params.permission_id, - data, + return await prismaClient.$transaction(async (tx) => { + return await updateTeamPermissionDefinitions(tx, { + project: auth.project, + permissionId: params.permission_id, + data, + }); }); }, async onDelete({ auth, params }) { - await deleteTeamPermissionDefinition({ - project: auth.project, - permissionId: params.permission_id + return await prismaClient.$transaction(async (tx) => { + await deleteTeamPermissionDefinition(tx, { + project: auth.project, + permissionId: params.permission_id + }); }); }, async onList({ auth }) { - return { - items: await listTeamPermissionDefinitions(auth.project), - is_paginated: false, - }; + return await prismaClient.$transaction(async (tx) => { + return { + items: await listTeamPermissionDefinitions(tx, auth.project), + is_paginated: false, + }; + }); }, }); diff --git a/apps/backend/src/app/api/v1/team-permissions/crud.tsx b/apps/backend/src/app/api/v1/team-permissions/crud.tsx index 71ea6750f8..1e33e1dc24 100644 --- a/apps/backend/src/app/api/v1/team-permissions/crud.tsx +++ b/apps/backend/src/app/api/v1/team-permissions/crud.tsx @@ -1,4 +1,5 @@ import { grantTeamPermission, listUserTeamPermissions, revokeTeamPermission } from "@/lib/permissions"; +import { prismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { getIdFromUserIdOrMe } from "@/route-handlers/utils"; import { teamPermissionsCrud } from '@stackframe/stack-shared/dist/interface/crud/team-permissions'; @@ -18,19 +19,23 @@ export const teamPermissionsCrudHandlers = createCrudHandlers(teamPermissionsCru permission_id: teamPermissionDefinitionIdSchema.required(), }), async onCreate({ auth, params }) { - return await grantTeamPermission({ - project: auth.project, - teamId: params.team_id, - userId: params.user_id, - permissionId: params.permission_id + return await prismaClient.$transaction(async (tx) => { + return await grantTeamPermission(tx, { + project: auth.project, + teamId: params.team_id, + userId: params.user_id, + permissionId: params.permission_id + }); }); }, async onDelete({ auth, params }) { - return await revokeTeamPermission({ - project: auth.project, - teamId: params.team_id, - userId: params.user_id, - permissionId: params.permission_id + return await prismaClient.$transaction(async (tx) => { + return await revokeTeamPermission(tx, { + project: auth.project, + teamId: params.team_id, + userId: params.user_id, + permissionId: params.permission_id + }); }); }, async onList({ auth, query }) { @@ -39,15 +44,17 @@ export const teamPermissionsCrudHandlers = createCrudHandlers(teamPermissionsCru throw new StatusError(StatusError.Forbidden, 'Client can only list permissions for their own user. user_id must be either "me" or the ID of the current user'); } - return { - items: await listUserTeamPermissions({ - project: auth.project, - teamId: query.team_id, - permissionId: query.permission_id, - userId, - recursive: query.recursive === 'true', - }), - is_paginated: false, - }; + return await prismaClient.$transaction(async (tx) => { + return { + items: await listUserTeamPermissions(tx, { + project: auth.project, + teamId: query.team_id, + permissionId: query.permission_id, + userId, + recursive: query.recursive === 'true', + }), + is_paginated: false, + }; + }); }, }); diff --git a/apps/backend/src/app/api/v1/teams/crud.tsx b/apps/backend/src/app/api/v1/teams/crud.tsx index 94d7c109b2..ffb710c501 100644 --- a/apps/backend/src/app/api/v1/teams/crud.tsx +++ b/apps/backend/src/app/api/v1/teams/crud.tsx @@ -1,4 +1,4 @@ -import { ensureTeamMembershipExist } from "@/lib/db-checks"; +import { ensureTeamExist, ensureTeamMembershipExist, ensureUserHasTeamPermission } from "@/lib/request-checks"; import { isTeamSystemPermission, teamSystemPermissionStringToDBType } from "@/lib/permissions"; import { sendWebhooks } from "@/lib/webhooks"; import { prismaClient } from "@/prisma-client"; @@ -92,7 +92,7 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC await ensureTeamMembershipExist(tx, { projectId: auth.project.id, teamId: params.team_id, - userId: auth.user?.id ?? throwErr("Client must be logged in to read a team"), + userId: auth.user?.id ?? throwErr('auth.user is null'), }); } @@ -115,29 +115,53 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC return teamPrismaToCrud(db); }, onUpdate: async ({ params, auth, data }) => { - const db = await prismaClient.team.update({ - where: { - projectId_teamId: { - projectId: auth.project.id, + const db = await prismaClient.$transaction(async (tx) => { + if (auth.type === 'client') { + await ensureUserHasTeamPermission(tx, { + project: auth.project, teamId: params.team_id, + userId: auth.user?.id ?? throwErr('auth.user is null'), + permissionId: "$update_team", + }); + } + + await ensureTeamExist(tx, { projectId: auth.project.id, teamId: params.team_id }); + + return await tx.team.update({ + where: { + projectId_teamId: { + projectId: auth.project.id, + teamId: params.team_id, + }, }, - }, - data: { - displayName: data.display_name, - profileImageUrl: data.profile_image_url, - }, + data: { + displayName: data.display_name, + profileImageUrl: data.profile_image_url, + }, + }); }); return teamPrismaToCrud(db); }, onDelete: async ({ params, auth }) => { - await prismaClient.team.delete({ - where: { - projectId_teamId: { - projectId: auth.project.id, + await prismaClient.$transaction(async (tx) => { + if (auth.type === 'client') { + await ensureUserHasTeamPermission(tx, { + project: auth.project, teamId: params.team_id, + userId: auth.user?.id ?? throwErr('auth.user is null'), + permissionId: "$delete_team", + }); + } + + await tx.team.delete({ + where: { + projectId_teamId: { + projectId: auth.project.id, + teamId: params.team_id, + }, }, - }, + }); }); await sendWebhooks({ diff --git a/apps/backend/src/lib/permissions.tsx b/apps/backend/src/lib/permissions.tsx index 3dda1dea4f..766e3c0e55 100644 --- a/apps/backend/src/lib/permissions.tsx +++ b/apps/backend/src/lib/permissions.tsx @@ -5,6 +5,7 @@ import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/proje import { TeamPermissionDefinitionsCrud, TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-permissions"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { typedToLowercase, typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; +import { PrismaTransaction } from "./types"; export const fullPermissionInclude = { parentEdges: { @@ -26,6 +27,8 @@ export function teamDBTypeToSystemPermissionString(permission: DBTeamSystemPermi return '$' + typedToLowercase(permission) as `$${Lowercase}`; } +export type TeamSystemPermission = ReturnType; + const descriptionMap: Record = { "UPDATE_TEAM": "Update the team information", "DELETE_TEAM": "Delete the team", @@ -64,10 +67,16 @@ export function teamPermissionDefinitionJsonFromTeamSystemDbType(db: DBTeamSyste } as const; } -async function getParentDbIds(project: ProjectsCrud["Admin"]["Read"], containedPermissionIds?: string[]) { +async function getParentDbIds( + tx: PrismaTransaction, + options: { + project: ProjectsCrud["Admin"]["Read"], + containedPermissionIds?: string[], + } +) { let parentDbIds = []; - const potentialParentPermissions = await listTeamPermissionDefinitions(project); - for (const parentPermissionId of containedPermissionIds || []) { + const potentialParentPermissions = await listTeamPermissionDefinitions(tx, options.project); + for (const parentPermissionId of options.containedPermissionIds || []) { const parentPermission = potentialParentPermissions.find(p => p.id === parentPermissionId); if (!parentPermission) { throw new KnownErrors.ContainedPermissionNotFound(parentPermissionId); @@ -79,23 +88,29 @@ async function getParentDbIds(project: ProjectsCrud["Admin"]["Read"], containedP } -export async function listUserTeamPermissions(options: { - project: ProjectsCrud["Admin"]["Read"], - teamId?: string, - userId?: string, - permissionId?: string, - recursive?: boolean, -}): Promise { - const allPermissions = await listTeamPermissionDefinitions(options.project); +export async function listUserTeamPermissions( + tx: PrismaTransaction, + options: { + project: ProjectsCrud["Admin"]["Read"], + teamId?: string, + userId?: string, + permissionId?: string, + recursive?: boolean, + } +): Promise { + const allPermissions = await listTeamPermissionDefinitions(tx, options.project); const permissionsMap = new Map(allPermissions.map(p => [p.id, p])); - const results = await prismaClient.teamMemberDirectPermission.findMany({ + const results = await tx.teamMemberDirectPermission.findMany({ where: { projectId: options.project.id, projectUserId: options.userId, teamId: options.teamId, - permission: options.permissionId ? { - queryableId: options.permissionId, - } : undefined + permission: options.permissionId && !isTeamSystemPermission(options.permissionId) ? + { queryableId: options.permissionId } : + undefined, + systemPermission: options.permissionId && isTeamSystemPermission(options.permissionId) ? + teamSystemPermissionStringToDBType(options.permissionId) : + undefined, }, include: { permission: true, @@ -143,14 +158,17 @@ export async function listUserTeamPermissions(options: { }); } -export async function grantTeamPermission(options: { - project: ProjectsCrud["Admin"]["Read"], - teamId: string, - userId: string, - permissionId: string, -}) { +export async function grantTeamPermission( + tx: PrismaTransaction, + options: { + project: ProjectsCrud["Admin"]["Read"], + teamId: string, + userId: string, + permissionId: string, + } +) { if (isTeamSystemPermission(options.permissionId)) { - await prismaClient.teamMemberDirectPermission.upsert({ + await tx.teamMemberDirectPermission.upsert({ where: { projectId_projectUserId_teamId_systemPermission: { projectId: options.project.id, @@ -174,7 +192,7 @@ export async function grantTeamPermission(options: { update: {}, }); } else { - const teamSpecificPermission = await prismaClient.permission.findUnique({ + const teamSpecificPermission = await tx.permission.findUnique({ where: { projectId_teamId_queryableId: { projectId: options.project.id, @@ -183,7 +201,7 @@ export async function grantTeamPermission(options: { }, } }); - const anyTeamPermission = await prismaClient.permission.findUnique({ + const anyTeamPermission = await tx.permission.findUnique({ where: { projectConfigId_queryableId: { projectConfigId: options.project.config.id, @@ -195,7 +213,7 @@ export async function grantTeamPermission(options: { const permission = teamSpecificPermission || anyTeamPermission; if (!permission) throw new KnownErrors.PermissionNotFound(options.permissionId); - const result = await prismaClient.teamMemberDirectPermission.upsert({ + await tx.teamMemberDirectPermission.upsert({ where: { projectId_projectUserId_teamId_permissionDbId: { projectId: options.project.id, @@ -231,14 +249,17 @@ export async function grantTeamPermission(options: { }; } -export async function revokeTeamPermission(options: { - project: ProjectsCrud["Admin"]["Read"], - teamId: string, - userId: string, - permissionId: string, -}) { +export async function revokeTeamPermission( + tx: PrismaTransaction, + options: { + project: ProjectsCrud["Admin"]["Read"], + teamId: string, + userId: string, + permissionId: string, + } +) { if (isTeamSystemPermission(options.permissionId)) { - await prismaClient.teamMemberDirectPermission.delete({ + await tx.teamMemberDirectPermission.delete({ where: { projectId_projectUserId_teamId_systemPermission: { projectId: options.project.id, @@ -251,7 +272,7 @@ export async function revokeTeamPermission(options: { return; } else { - const teamSpecificPermission = await prismaClient.permission.findUnique({ + const teamSpecificPermission = await tx.permission.findUnique({ where: { projectId_teamId_queryableId: { projectId: options.project.id, @@ -260,7 +281,7 @@ export async function revokeTeamPermission(options: { }, } }); - const anyTeamPermission = await prismaClient.permission.findUnique({ + const anyTeamPermission = await tx.permission.findUnique({ where: { projectConfigId_queryableId: { projectConfigId: options.project.config.id, @@ -272,7 +293,7 @@ export async function revokeTeamPermission(options: { const permission = teamSpecificPermission || anyTeamPermission; if (!permission) throw new KnownErrors.PermissionNotFound(options.permissionId); - await prismaClient.teamMemberDirectPermission.delete({ + await tx.teamMemberDirectPermission.delete({ where: { projectId_projectUserId_teamId_permissionDbId: { projectId: options.project.id, @@ -286,8 +307,11 @@ export async function revokeTeamPermission(options: { } -export async function listTeamPermissionDefinitions(project: ProjectsCrud["Admin"]["Read"]): Promise<(TeamPermissionDefinitionsCrud["Admin"]["Read"] & { __database_id: string })[]> { - const res = await prismaClient.permission.findMany({ +export async function listTeamPermissionDefinitions( + tx: PrismaTransaction, + project: ProjectsCrud["Admin"]["Read"] +): Promise<(TeamPermissionDefinitionsCrud["Admin"]["Read"] & { __database_id: string })[]> { + const res = await tx.permission.findMany({ where: { projectConfig: { projects: { @@ -308,16 +332,22 @@ export async function listTeamPermissionDefinitions(project: ProjectsCrud["Admin return [...nonSystemPermissions, ...systemPermissions]; } -export async function createTeamPermissionDefinition(options: { - project: ProjectsCrud["Admin"]["Read"], - data: { - id: string, - description?: string, - contained_permission_ids?: string[], - }, -}) { - const parentDbIds = await getParentDbIds(options.project, options.data.contained_permission_ids); - const dbPermission = await prismaClient.permission.create({ +export async function createTeamPermissionDefinition( + tx: PrismaTransaction, + options: { + project: ProjectsCrud["Admin"]["Read"], + data: { + id: string, + description?: string, + contained_permission_ids?: string[], + }, + } +) { + const parentDbIds = await getParentDbIds(tx, { + project: options.project, + containedPermissionIds: options.data.contained_permission_ids + }); + const dbPermission = await tx.permission.create({ data: { scope: "TEAM", queryableId: options.data.id, @@ -346,16 +376,22 @@ export async function createTeamPermissionDefinition(options: { return teamPermissionDefinitionJsonFromDbType(dbPermission); } -export async function updateTeamPermissionDefinitions(options: { - project: ProjectsCrud["Admin"]["Read"], - permissionId: string, - data: { - id?: string, - description?: string, - contained_permission_ids?: string[], - }, -}) { - const parentDbIds = await getParentDbIds(options.project, options.data.contained_permission_ids); +export async function updateTeamPermissionDefinitions( + tx: PrismaTransaction, + options: { + project: ProjectsCrud["Admin"]["Read"], + permissionId: string, + data: { + id?: string, + description?: string, + contained_permission_ids?: string[], + }, + } +) { + const parentDbIds = await getParentDbIds(tx, { + project: options.project, + containedPermissionIds: options.data.contained_permission_ids + }); let edgeUpdateData = {}; if (options.data.contained_permission_ids) { @@ -381,7 +417,7 @@ export async function updateTeamPermissionDefinitions(options: { }; } - const db = await prismaClient.permission.update({ + const db = await tx.permission.update({ where: { projectConfigId_queryableId: { projectConfigId: options.project.config.id, @@ -399,11 +435,14 @@ export async function updateTeamPermissionDefinitions(options: { return teamPermissionDefinitionJsonFromDbType(db); } -export async function deleteTeamPermissionDefinition(options: { - project: ProjectsCrud["Admin"]["Read"], - permissionId: string, -}) { - const deleted = await prismaClient.permission.deleteMany({ +export async function deleteTeamPermissionDefinition( + tx: PrismaTransaction, + options: { + project: ProjectsCrud["Admin"]["Read"], + permissionId: string, + } +) { + const deleted = await tx.permission.deleteMany({ where: { projectConfigId: options.project.config.id, queryableId: options.permissionId, diff --git a/apps/backend/src/lib/db-checks.tsx b/apps/backend/src/lib/request-checks.tsx similarity index 62% rename from apps/backend/src/lib/db-checks.tsx rename to apps/backend/src/lib/request-checks.tsx index 918635fc04..44cc45ddba 100644 --- a/apps/backend/src/lib/db-checks.tsx +++ b/apps/backend/src/lib/request-checks.tsx @@ -1,5 +1,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { PrismaTransaction } from "./types"; +import { TeamSystemPermission, listUserTeamPermissions } from "./permissions"; +import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects"; async function _getTeamMembership( @@ -69,4 +71,32 @@ export async function ensureTeamExist( if (!team) { throw new KnownErrors.TeamNotFound(options.teamId); } +} + +export async function ensureUserHasTeamPermission( + tx: PrismaTransaction, + options: { + project: ProjectsCrud["Admin"]["Read"], + teamId: string, + userId: string, + permissionId: TeamSystemPermission, + } +) { + await ensureTeamMembershipExist(tx, { + projectId: options.project.id, + teamId: options.teamId, + userId: options.userId, + }); + + const result = await listUserTeamPermissions(tx, { + project: options.project, + teamId: options.teamId, + userId: options.userId, + permissionId: options.permissionId, + recursive: true, + }); + + if (result.length === 0) { + throw new KnownErrors.TeamPermissionRequired(options.teamId, options.userId, options.permissionId); + } } \ No newline at end of file diff --git a/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts index f6ec0dea7a..7ec8d89ebe 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts @@ -46,7 +46,7 @@ describe("without project ID", () => { "body": { "code": "ACCESS_TYPE_WITHOUT_PROJECT_ID", "details": { "request_type": "client" }, - "error": "The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided.", + "error": "The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided.\\n\\nFor more information, see the docs on REST API authentication: https://docs.stack-auth.com/rest-api/auth#authentication", }, "headers": Headers { "x-stack-known-error": "ACCESS_TYPE_WITHOUT_PROJECT_ID", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/api-keys.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/api-keys.test.ts index 86af0ccd63..30c3b9463c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/api-keys.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/api-keys.test.ts @@ -16,7 +16,7 @@ describe("without project access", () => { "body": { "code": "ACCESS_TYPE_WITHOUT_PROJECT_ID", "details": { "request_type": "client" }, - "error": "The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided.", + "error": "The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided.\\n\\nFor more information, see the docs on REST API authentication: https://docs.stack-auth.com/rest-api/auth#authentication", }, "headers": Headers { "x-stack-known-error": "ACCESS_TYPE_WITHOUT_PROJECT_ID", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts index fdcbcbd740..55cab418b4 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts @@ -11,7 +11,7 @@ it("should not have have access to the project", async ({ expect }) => { "body": { "code": "ACCESS_TYPE_WITHOUT_PROJECT_ID", "details": { "request_type": "client" }, - "error": "The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided.", + "error": "The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided.\\n\\nFor more information, see the docs on REST API authentication: https://docs.stack-auth.com/rest-api/auth#authentication", }, "headers": Headers { "x-stack-known-error": "ACCESS_TYPE_WITHOUT_PROJECT_ID", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts index a167c6055c..d2eb8dd6c6 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts @@ -13,7 +13,7 @@ it("should not have have access to the project", async ({ expect }) => { "body": { "code": "ACCESS_TYPE_WITHOUT_PROJECT_ID", "details": { "request_type": "client" }, - "error": "The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided.", + "error": "The x-stack-access-type header was 'client', but the x-stack-project-id header was not provided.\\n\\nFor more information, see the docs on REST API authentication: https://docs.stack-auth.com/rest-api/auth#authentication", }, "headers": Headers { "x-stack-known-error": "ACCESS_TYPE_WITHOUT_PROJECT_ID", diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts index ecb7c3d129..5bccf2b97e 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-memberships.test.ts @@ -33,7 +33,7 @@ it("is not allowed to add user to team on client", async ({ expect }) => { `); }); -it("creates a team and manage users in it", async ({ expect }) => { +it("creates a team and manage users on the server", async ({ expect }) => { const { userId: userId1 } = await Auth.Otp.signIn(); backendContext.set({ mailbox: createMailbox(), @@ -185,3 +185,82 @@ it("should give team creator default permissions", async ({ expect }) => { } `); }); + +it("can leave team", async ({ expect }) => { + await Auth.Otp.signIn(); + const { teamId } = await Team.create(); + + // Does not have permission to remove user from team + const response1 = await niceBackendFetch(`/api/v1/team-memberships/${teamId}/me`, { + accessType: "client", + method: "DELETE", + body: {}, + }); + expect(response1).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "success": true }, + "headers": Headers {