Skip to content

Commit

Permalink
Advanced team invitations
Browse files Browse the repository at this point in the history
  • Loading branch information
N2D4 committed Nov 26, 2024
1 parent 7ee4858 commit c1b8601
Show file tree
Hide file tree
Showing 35 changed files with 2,230 additions and 1,397 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "VerificationCode_data_idx" ON "VerificationCode" USING GIN ("data" jsonb_path_ops);
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ model VerificationCode {
@@id([projectId, id])
@@unique([projectId, code])
@@index([data(ops: JsonbPathOps)], type: Gin)
}

enum VerificationCodeType {
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/src/app/api/v1/team-invitations/[id]/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { teamInvitationsCrudHandlers } from "../crud";

export const DELETE = teamInvitationsCrudHandlers.deleteHandler;
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
teamDisplayName: team.display_name,
},
});

return codeObj;
},
async handler(project, {}, data, body, user) {
if (!user) throw new KnownErrors.UserAuthenticationRequired;
Expand Down
89 changes: 89 additions & 0 deletions apps/backend/src/app/api/v1/team-invitations/crud.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { ensureTeamExists, ensureTeamMembershipExists, ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { prismaClient } from "@/prisma-client";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { teamInvitationCrud } from "@stackframe/stack-shared/dist/interface/crud/team-invitation";
import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";
import { teamInvitationCodeHandler } from "./accept/verification-code-handler";

export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamInvitationCrud, {
querySchema: yupObject({
team_id: yupString().uuid().defined().meta({ openapiField: { onlyShowInOperations: ['List'] }}),
}),
paramsSchema: yupObject({
id: yupString().uuid().defined(),
}),
onList: async ({ auth, query }) => {
return await prismaClient.$transaction(async (tx) => {
if (auth.type === 'client') {
// Client can only:
// - list invitations in their own team if they have the $read_members AND $invite_members permissions

const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());

await ensureTeamMembershipExists(tx, { projectId: auth.project.id, teamId: query.team_id, userId: currentUserId });

for (const permissionId of ['$read_members', '$invite_members']) {
await ensureUserTeamPermissionExists(tx, {
project: auth.project,
teamId: query.team_id,
userId: currentUserId,
permissionId,
errorType: 'required',
recursive: true,
});
}
} else {
await ensureTeamExists(tx, { projectId: auth.project.id, teamId: query.team_id });
}

const allCodes = await teamInvitationCodeHandler.listCodes({
project: auth.project,
dataFilter: {
path: ['team_id'],
equals: query.team_id,
},
});

return {
items: allCodes.map(code => ({
id: code.id,
team_id: code.data.team_id,
expires_at_millis: code.expiresAt.getTime(),
recipient_email: code.method.email,
})),
is_paginated: false,
};
});
},
onDelete: async ({ auth, query, params }) => {
return await prismaClient.$transaction(async (tx) => {
if (auth.type === 'client') {
// Client can only:
// - delete invitations in their own team if they have the $remove_members permissions

const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());

await ensureTeamMembershipExists(tx, { projectId: auth.project.id, teamId: query.team_id, userId: currentUserId });

await ensureUserTeamPermissionExists(tx, {
project: auth.project,
teamId: query.team_id,
userId: currentUserId,
permissionId: "$remove_members",
errorType: 'required',
recursive: true,
});
} else {
await ensureTeamExists(tx, { projectId: auth.project.id, teamId: query.team_id });
}

await teamInvitationCodeHandler.revokeCode({
project: auth.project,
id: params.id,
});
});
},
}));
3 changes: 3 additions & 0 deletions apps/backend/src/app/api/v1/team-invitations/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { teamInvitationsCrudHandlers } from "./crud";

export const GET = teamInvitationsCrudHandlers.listHandler;
16 changes: 12 additions & 4 deletions apps/backend/src/app/api/v1/team-invitations/send-code/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { prismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { teamInvitationCodeHandler } from "../accept/verification-code-handler";

export const POST = createSmartRouteHandler({
Expand All @@ -25,7 +25,11 @@ export const POST = createSmartRouteHandler({
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["success"]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
success: yupBoolean().oneOf([true]).defined(),
id: yupString().uuid().defined(),
}).defined(),
}),
async handler({ auth, body }) {
await prismaClient.$transaction(async (tx) => {
Expand All @@ -43,7 +47,7 @@ export const POST = createSmartRouteHandler({
}
});

await teamInvitationCodeHandler.sendCode({
const codeObj = await teamInvitationCodeHandler.sendCode({
project: auth.project,
data: {
team_id: body.team_id,
Expand All @@ -56,7 +60,11 @@ export const POST = createSmartRouteHandler({

return {
statusCode: 200,
bodyType: "success",
bodyType: "json",
body: {
success: true,
id: codeObj.id,
},
};
},
});
11 changes: 6 additions & 5 deletions apps/backend/src/oauth/providers/base.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Issuer, generators, CallbackParamsType, Client, TokenSet as OIDCTokenSet } from "openid-client";
import { OAuthUserInfo } from "../utils";
import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings";
import { KnownErrors } from "@stackframe/stack-shared";
import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings";
import { CallbackParamsType, Client, Issuer, TokenSet as OIDCTokenSet, generators } from "openid-client";
import { OAuthUserInfo } from "../utils";

export type TokenSet = {
accessToken: string,
Expand Down Expand Up @@ -151,8 +151,9 @@ export abstract class OAuthBaseProvider {
if (error?.error === "invalid_grant") {
// while this is technically a "user" error, it would only be caused by a client that is not properly implemented
// to catch the case where our own client is not properly implemented, we capture the error here
// TODO is the comment above actually true? This is inner OAuth, not outer OAuth, so why does the client implementation matter?
captureError("inner-oauth-callback", error);
throw new KnownErrors.InvalidAuthorizationCode();
throw new StatusError(400, "Inner OAuth callback failed due to invalid grant; something went wrong with authorization code exchange");
}
if (error?.error === 'access_denied') {
throw new KnownErrors.OAuthProviderAccessDenied();
Expand Down
98 changes: 73 additions & 25 deletions apps/backend/src/route-handlers/verification-code-handler.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import * as yup from "yup";
import { SmartRouteHandler, SmartRouteHandlerOverloadMetadata, createSmartRouteHandler } from "./smart-route-handler";
import { SmartResponse } from "./smart-response";
import { KnownErrors } from "@stackframe/stack-shared";
import { prismaClient } from "@/prisma-client";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { validateRedirectUrl } from "@/lib/redirect-urls";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { VerificationCodeType } from "@prisma/client";
import { SmartRequest } from "./smart-request";
import { DeepPartial } from "@stackframe/stack-shared/dist/utils/objects";
import { prismaClient } from "@/prisma-client";
import { Prisma, VerificationCodeType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { DeepPartial } from "@stackframe/stack-shared/dist/utils/objects";
import * as yup from "yup";
import { SmartRequest } from "./smart-request";
import { SmartResponse } from "./smart-response";
import { SmartRouteHandler, SmartRouteHandlerOverloadMetadata, createSmartRouteHandler } from "./smart-route-handler";

const MAX_ATTEMPTS_PER_CODE = 20;

Expand All @@ -23,15 +23,30 @@ type CreateCodeOptions<Data, Method extends {}, CallbackUrl extends string | URL
callbackUrl: CallbackUrl,
};

type CodeObject<CallbackUrl extends string | URL | undefined> = {
type ListCodesOptions<Data> = {
project: ProjectsCrud["Admin"]["Read"],
dataFilter?: Prisma.JsonFilter<"VerificationCode"> | undefined,
}

type RevokeCodeOptions = {
project: ProjectsCrud["Admin"]["Read"],
id: string,
}

type CodeObject<Data, Method extends {}, CallbackUrl extends string | URL | undefined> = {
id: string,
data: Data,
method: Method,
code: string,
link: CallbackUrl extends string | URL ? URL : undefined,
expiresAt: Date,
};

type VerificationCodeHandler<Data, SendCodeExtraOptions extends {}, SendCodeReturnType, HasDetails extends boolean, Method extends {}> = {
createCode<CallbackUrl extends string | URL | undefined>(options: CreateCodeOptions<Data, Method, CallbackUrl>): Promise<CodeObject<CallbackUrl>>,
createCode<CallbackUrl extends string | URL | undefined>(options: CreateCodeOptions<Data, Method, CallbackUrl>): Promise<CodeObject<Data, Method, CallbackUrl>>,
sendCode(options: CreateCodeOptions<Data, Method, string | URL>, sendOptions: SendCodeExtraOptions): Promise<SendCodeReturnType>,
listCodes(options: ListCodesOptions<Data>): Promise<CodeObject<Data, Method, string | URL>[]>,
revokeCode(options: RevokeCodeOptions): Promise<void>,
postHandler: SmartRouteHandler<any, any, any>,
checkHandler: SmartRouteHandler<any, any, any>,
detailsHandler: HasDetails extends true ? SmartRouteHandler<any, any, any> : undefined,
Expand Down Expand Up @@ -61,7 +76,7 @@ export function createVerificationCodeHandler<
detailsResponse?: yup.Schema<DetailsResponse>,
response: yup.Schema<Response>,
send?(
codeObject: CodeObject<string | URL>,
codeObject: CodeObject<Data, Method, string | URL>,
createOptions: CreateCodeOptions<Data, Method, string | URL>,
sendOptions: SendCodeExtraOptions,
): Promise<SendCodeReturnType>,
Expand All @@ -70,7 +85,7 @@ export function createVerificationCodeHandler<
method: Method,
data: Data,
body: RequestBody,
user:UsersCrud["Admin"]["Read"] | undefined
user: UsersCrud["Admin"]["Read"] | undefined
): Promise<void>,
handler(
project: ProjectsCrud["Admin"]["Read"],
Expand Down Expand Up @@ -214,16 +229,7 @@ export function createVerificationCodeHandler<
}
});

let link;
if (callbackUrl !== undefined) {
link = new URL(callbackUrl);
link.searchParams.set('code', verificationCodePrisma.code);
}

return {
code: verificationCodePrisma.code,
link,
} as any;
return createCodeObjectFromPrismaCode(verificationCodePrisma);
},
async sendCode(createOptions, sendOptions) {
const codeObj = await this.createCode(createOptions);
Expand All @@ -232,8 +238,50 @@ export function createVerificationCodeHandler<
}
return await options.send(codeObj, createOptions, sendOptions);
},
async listCodes(listOptions) {
const codes = await prismaClient.verificationCode.findMany({
where: {
projectId: listOptions.project.id,
type: options.type,
data: listOptions.dataFilter,
expiresAt: {
gt: new Date(),
},
usedAt: null,
},
});
return codes.map(code => createCodeObjectFromPrismaCode(code));
},
async revokeCode(options) {
await prismaClient.verificationCode.delete({
where: {
projectId_id: {
projectId: options.project.id,
id: options.id,
},
},
});
},
postHandler: createHandler('post'),
checkHandler: createHandler('check'),
detailsHandler: (options.detailsResponse ? createHandler('details') : undefined) as any,
};
}


function createCodeObjectFromPrismaCode<Data, Method extends {}, CallbackUrl extends string | URL | undefined>(code: Prisma.VerificationCodeGetPayload<{}>): CodeObject<Data, Method, CallbackUrl> {
let link: URL | undefined;
if (code.redirectUrl !== null) {
link = new URL(code.redirectUrl);
link.searchParams.set('code', code.code);
}

return {
id: code.id,
data: code.data as Data,
method: code.method as Method,
code: code.code,
link: link as any,
expiresAt: code.expiresAt,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function CreateDialog(props: {
trigger={props.trigger}
title={props.type === "creator" ? "Team Creator Default Permissions" : "Team Member Default Permissions"}
formSchema={formSchema}
okButton={{ label: "Create" }}
okButton={{ label: "Save" }}
onSubmit={async (values) => {
if (props.type === "creator") {
await project.update({
Expand Down
Loading

0 comments on commit c1b8601

Please sign in to comment.