diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index ba1105c81..0ca58fdc2 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -4,13 +4,13 @@ import { getAuditService } from "@/ee/features/audit/factory"; import { env, getSMTPConnectionURL } from "@sourcebot/shared"; import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, orgNotFound, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; +import { notFound, orgNotFound, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError"; import { getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils"; import { prisma } from "@/prisma"; import { render } from "@react-email/components"; import * as Sentry from '@sentry/nextjs'; import { generateApiKey, getTokenFromConfig, hashSecret } from "@sourcebot/shared"; -import { ApiKey, ConnectionSyncJobStatus, Org, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db"; +import { ApiKey, ConnectionSyncJobStatus, OrgRole, Prisma, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db"; import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; @@ -20,14 +20,13 @@ import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; import { createTransport } from "nodemailer"; import { Octokit } from "octokit"; -import { auth } from "./auth"; import { getOrgFromDomain } from "./data/org"; import InviteUserEmail from "./emails/inviteUserEmail"; import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; -import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; +import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { ApiKeyPayload, RepositoryQuery } from "./lib/types"; -import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2"; +import { withAuthV2, withOptionalAuthV2, withMinimumOrgRole, withAuthV2_skipOrgMembershipCheck } from "./withAuthV2"; import { getBrowsePath } from "./app/[domain]/browse/hooks/utils"; const logger = createLogger('web-actions'); @@ -54,139 +53,20 @@ export const sew = async (fn: () => Promise): Promise => } } -export const withAuth = async (fn: (userId: string, apiKeyHash: string | undefined) => Promise, allowAnonymousAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { - const session = await auth(); - - if (!session) { - // First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not, - // then this is an invalid unauthed request and we return a 401. - const anonymousAccessEnabled = await getAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN); - if (apiKey) { - const apiKeyOrError = await verifyApiKey(apiKey); - if (isServiceError(apiKeyOrError)) { - logger.error(`Invalid API key: ${JSON.stringify(apiKey)}. Error: ${JSON.stringify(apiKeyOrError)}`); - return notAuthenticated(); - } - - const user = await prisma.user.findUnique({ - where: { - id: apiKeyOrError.apiKey.createdById, - }, - }); - - if (!user) { - logger.error(`No user found for API key: ${apiKey}`); - return notAuthenticated(); - } - - if (env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true') { - const membership = await prisma.userToOrg.findFirst({ - where: { userId: user.id }, - }); - if (membership?.role !== OrgRole.OWNER) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.API_KEY_USAGE_DISABLED, - message: "API key usage is disabled for non-admin users.", - } satisfies ServiceError; - } - } - - await prisma.apiKey.update({ - where: { - hash: apiKeyOrError.apiKey.hash, - }, - data: { - lastUsedAt: new Date(), - }, - }); - - return fn(user.id, apiKeyOrError.apiKey.hash); - } else if ( - allowAnonymousAccess && - !isServiceError(anonymousAccessEnabled) && - anonymousAccessEnabled - ) { - if (!hasEntitlement("anonymous-access")) { - const plan = getPlan(); - logger.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); - return notAuthenticated(); - } - - // To support anonymous access a guest user is created in initialize.ts, which we return here - return fn(SOURCEBOT_GUEST_USER_ID, undefined); - } - return notAuthenticated(); - } - return fn(session.user.id, undefined); -} - -export const withOrgMembership = async (userId: string, domain: string, fn: (params: { userRole: OrgRole, org: Org }) => Promise, minRequiredRole: OrgRole = OrgRole.MEMBER) => { - const org = await prisma.org.findUnique({ - where: { - domain, - }, - }); - - if (!org) { - return notFound("Organization not found"); - } - - const membership = await prisma.userToOrg.findUnique({ - where: { - orgId_userId: { - userId, - orgId: org.id, +////// Actions /////// +export const completeOnboarding = async (): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuthV2(async ({ org, prisma }) => { + await prisma.org.update({ + where: { id: org.id }, + data: { + isOnboarded: true, } - }, - }); - - if (!membership) { - return notFound("User not a member of this organization"); - } - - const getAuthorizationPrecedence = (role: OrgRole): number => { - switch (role) { - case OrgRole.GUEST: - return 0; - case OrgRole.MEMBER: - return 1; - case OrgRole.OWNER: - return 2; - } - } - + }); - if (getAuthorizationPrecedence(membership.role) < getAuthorizationPrecedence(minRequiredRole)) { return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, - message: "You do not have sufficient permissions to perform this action.", - } satisfies ServiceError; - } - - return fn({ - org: org, - userRole: membership.role, - }); -} - -////// Actions /////// -export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - await prisma.org.update({ - where: { id: org.id }, - data: { - isOnboarded: true, - } - }); - - return { - success: true, - } - }) - )); + success: true, + } + })); export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiKey: ApiKey } | ServiceError> => sew(async () => { const parts = apiKeyPayload.apiKey.split("-"); @@ -241,157 +121,154 @@ export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiK }); -export const createApiKey = async (name: string, domain: string): Promise<{ key: string } | ServiceError> => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org, userRole }) => { - if ((env.DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS === 'true' || env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true') && userRole !== OrgRole.OWNER) { - logger.error(`API key creation is disabled for non-admin users. User ${userId} is not an owner.`); - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, - message: "API key creation is disabled for non-admin users.", - } satisfies ServiceError; - } - - const existingApiKey = await prisma.apiKey.findFirst({ - where: { - createdById: userId, - name, - }, - }); - - if (existingApiKey) { - await auditService.createAudit({ - action: "api_key.creation_failed", - actor: { - id: userId, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: `API key ${name} already exists`, - api_key: name - } - }); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.API_KEY_ALREADY_EXISTS, - message: `API key ${name} already exists`, - } satisfies ServiceError; - } +export const createApiKey = async (name: string): Promise<{ key: string } | ServiceError> => sew(() => + withAuthV2(async ({ org, user, role, prisma }) => { + if ((env.DISABLE_API_KEY_CREATION_FOR_NON_OWNER_USERS === 'true' || env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'true') && role !== OrgRole.OWNER) { + logger.error(`API key creation is disabled for non-admin users. User ${user.id} is not an owner.`); + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: "API key creation is disabled for non-admin users.", + } satisfies ServiceError; + } - const { key, hash } = generateApiKey(); - const apiKey = await prisma.apiKey.create({ - data: { - name, - hash, - orgId: org.id, - createdById: userId, - } - }); + const existingApiKey = await prisma.apiKey.findFirst({ + where: { + createdById: user.id, + name, + }, + }); + if (existingApiKey) { await auditService.createAudit({ - action: "api_key.created", + action: "api_key.creation_failed", actor: { - id: userId, + id: user.id, type: "user" }, target: { - id: apiKey.hash, - type: "api_key" + id: org.id.toString(), + type: "org" }, - orgId: org.id + orgId: org.id, + metadata: { + message: `API key ${name} already exists`, + api_key: name + } }); - return { - key, + statusCode: StatusCodes.BAD_REQUEST, + errorCode: ErrorCode.API_KEY_ALREADY_EXISTS, + message: `API key ${name} already exists`, + } satisfies ServiceError; + } + + const { key, hash } = generateApiKey(); + const apiKey = await prisma.apiKey.create({ + data: { + name, + hash, + orgId: org.id, + createdById: user.id, } - }))); + }); -export const deleteApiKey = async (name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const apiKey = await prisma.apiKey.findFirst({ - where: { - name, - createdById: userId, - }, - }); + await auditService.createAudit({ + action: "api_key.created", + actor: { + id: user.id, + type: "user" + }, + target: { + id: apiKey.hash, + type: "api_key" + }, + orgId: org.id + }); - if (!apiKey) { - await auditService.createAudit({ - action: "api_key.deletion_failed", - actor: { - id: userId, - type: "user" - }, - target: { - id: domain, - type: "org" - }, - orgId: org.id, - metadata: { - message: `API key ${name} not found for user ${userId}`, - api_key: name - } - }); - return { - statusCode: StatusCodes.NOT_FOUND, - errorCode: ErrorCode.API_KEY_NOT_FOUND, - message: `API key ${name} not found for user ${userId}`, - } satisfies ServiceError; - } + return { + key, + } + })); - await prisma.apiKey.delete({ - where: { - hash: apiKey.hash, - }, - }); +export const deleteApiKey = async (name: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuthV2(async ({ org, user, prisma }) => { + const apiKey = await prisma.apiKey.findFirst({ + where: { + name, + createdById: user.id, + }, + }); + if (!apiKey) { await auditService.createAudit({ - action: "api_key.deleted", + action: "api_key.deletion_failed", actor: { - id: userId, + id: user.id, type: "user" }, target: { - id: apiKey.hash, - type: "api_key" + id: org.domain, + type: "org" }, orgId: org.id, metadata: { + message: `API key ${name} not found for user ${user.id}`, api_key: name } }); - return { - success: true, + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.API_KEY_NOT_FOUND, + message: `API key ${name} not found for user ${user.id}`, + } satisfies ServiceError; + } + + await prisma.apiKey.delete({ + where: { + hash: apiKey.hash, + }, + }); + + await auditService.createAudit({ + action: "api_key.deleted", + actor: { + id: user.id, + type: "user" + }, + target: { + id: apiKey.hash, + type: "api_key" + }, + orgId: org.id, + metadata: { + api_key: name } - }))); + }); -export const getUserApiKeys = async (domain: string): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const apiKeys = await prisma.apiKey.findMany({ - where: { - orgId: org.id, - createdById: userId, - }, - orderBy: { - createdAt: 'desc', - } - }); + return { + success: true, + } + })); + +export const getUserApiKeys = async (): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() => + withAuthV2(async ({ org, user, prisma }) => { + const apiKeys = await prisma.apiKey.findMany({ + where: { + orgId: org.id, + createdById: user.id, + }, + orderBy: { + createdAt: 'desc', + } + }); - return apiKeys.map((apiKey) => ({ - name: apiKey.name, - createdAt: apiKey.createdAt, - lastUsedAt: apiKey.lastUsedAt, - })); - }))); + return apiKeys.map((apiKey) => ({ + name: apiKey.name, + createdAt: apiKey.createdAt, + lastUsedAt: apiKey.lastUsedAt, + })); + })); export const getRepos = async ({ where, @@ -717,21 +594,19 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin } })); -export const getCurrentUserRole = async (domain: string): Promise => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ userRole }) => { - return userRole; - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true - )); +export const getCurrentUserRole = async (): Promise => sew(() => + withOptionalAuthV2(async ({ role }) => { + return role; + })); -export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { +export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuthV2(async ({ org, user, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { const failAuditCallback = async (error: string) => { await auditService.createAudit({ action: "user.invite_failed", actor: { - id: userId, + id: user.id, type: "user" }, target: { @@ -745,17 +620,13 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ } }); } - const user = await getMe(); - if (isServiceError(user)) { - throw new ServiceErrorException(user); - } - const hasAvailability = await orgHasAvailability(domain); + const hasAvailability = await orgHasAvailability(org.domain); if (!hasAvailability) { await auditService.createAudit({ action: "user.invite_failed", actor: { - id: userId, + id: user.id, type: "user" }, target: { @@ -818,7 +689,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ await prisma.invite.createMany({ data: emails.map((email) => ({ recipientEmail: email, - hostUserId: userId, + hostUserId: user.id, orgId: org.id, })), skipDuplicates: true, @@ -885,7 +756,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ await auditService.createAudit({ action: "user.invites_created", actor: { - id: userId, + id: user.id, type: "user" }, target: { @@ -900,12 +771,12 @@ export const createInvites = async (emails: string[], domain: string): Promise<{ return { success: true, } - }, /* minRequiredRole = */ OrgRole.OWNER) + }) )); -export const cancelInvite = async (inviteId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { +export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => + withAuthV2(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { const invite = await prisma.invite.findUnique({ where: { id: inviteId, @@ -926,14 +797,14 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{ return { success: true, } - }, /* minRequiredRole = */ OrgRole.OWNER) + }) )); export const getMe = async () => sew(() => - withAuth(async (userId) => { - const user = await prisma.user.findUnique({ + withAuthV2(async ({ user, prisma }) => { + const userWithOrgs = await prisma.user.findUnique({ where: { - id: userId, + id: user.id, }, include: { orgs: { @@ -944,16 +815,16 @@ export const getMe = async () => sew(() => } }); - if (!user) { + if (!userWithOrgs) { return notFound(); } return { - id: user.id, - email: user.email, - name: user.name, - image: user.image, - memberships: user.orgs.map((org) => ({ + id: userWithOrgs.id, + email: userWithOrgs.email, + name: userWithOrgs.name, + image: userWithOrgs.image, + memberships: userWithOrgs.orgs.map((org) => ({ id: org.orgId, role: org.role, domain: org.org.domain, @@ -963,12 +834,7 @@ export const getMe = async () => sew(() => })); export const redeemInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async () => { - const user = await getMe(); - if (isServiceError(user)) { - return user; - } - + withAuthV2_skipOrgMembershipCheck(async ({ user, prisma }) => { const invite = await prisma.invite.findUnique({ where: { id: inviteId, @@ -1042,12 +908,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean })); export const getInviteInfo = async (inviteId: string) => sew(() => - withAuth(async () => { - const user = await getMe(); - if (isServiceError(user)) { - return user; - } - + withAuthV2_skipOrgMembershipCheck(async ({ user, prisma }) => { const invite = await prisma.invite.findUnique({ where: { id: inviteId, @@ -1083,69 +944,63 @@ export const getInviteInfo = async (inviteId: string) => sew(() => } })); -export const getOrgMembers = async (domain: string) => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const members = await prisma.userToOrg.findMany({ - where: { - orgId: org.id, - role: { - not: OrgRole.GUEST, - } - }, - include: { - user: true, - }, - }); +export const getOrgMembers = async () => sew(() => + withAuthV2(async ({ org, prisma }) => { + const members = await prisma.userToOrg.findMany({ + where: { + orgId: org.id, + role: { + not: OrgRole.GUEST, + } + }, + include: { + user: true, + }, + }); - return members.map((member) => ({ - id: member.userId, - email: member.user.email!, - name: member.user.name ?? undefined, - avatarUrl: member.user.image ?? undefined, - role: member.role, - joinedAt: member.joinedAt, - })); - }) - )); + return members.map((member) => ({ + id: member.userId, + email: member.user.email!, + name: member.user.name ?? undefined, + avatarUrl: member.user.image ?? undefined, + role: member.role, + joinedAt: member.joinedAt, + })); + })); -export const getOrgInvites = async (domain: string) => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const invites = await prisma.invite.findMany({ - where: { - orgId: org.id, - }, - }); +export const getOrgInvites = async () => sew(() => + withAuthV2(async ({ org, prisma }) => { + const invites = await prisma.invite.findMany({ + where: { + orgId: org.id, + }, + }); - return invites.map((invite) => ({ - id: invite.id, - email: invite.recipientEmail, - createdAt: invite.createdAt, - })); - }) - )); + return invites.map((invite) => ({ + id: invite.id, + email: invite.recipientEmail, + createdAt: invite.createdAt, + })); + })); -export const getOrgAccountRequests = async (domain: string) => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const requests = await prisma.accountRequest.findMany({ - where: { - orgId: org.id, - }, - include: { - requestedBy: true, - }, - }); +export const getOrgAccountRequests = async () => sew(() => + withAuthV2(async ({ org, prisma }) => { + const requests = await prisma.accountRequest.findMany({ + where: { + orgId: org.id, + }, + include: { + requestedBy: true, + }, + }); - return requests.map((request) => ({ - id: request.id, - email: request.requestedBy.email!, - createdAt: request.createdAt, - name: request.requestedBy.name ?? undefined, - })); - }) - )); + return requests.map((request) => ({ + id: request.id, + email: request.requestedBy.email!, + createdAt: request.createdAt, + name: request.requestedBy.name ?? undefined, + })); + })); export const createAccountRequest = async (userId: string, domain: string) => sew(async () => { const user = await prisma.user.findUnique({ @@ -1264,9 +1119,9 @@ export const getMemberApprovalRequired = async (domain: string): Promise => sew(async () => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { +export const setMemberApprovalRequired = async (required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuthV2(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { await prisma.org.update({ where: { id: org.id }, data: { memberApprovalRequired: required }, @@ -1275,13 +1130,13 @@ export const setMemberApprovalRequired = async (domain: string, required: boolea return { success: true, }; - }, /* minRequiredRole = */ OrgRole.OWNER) + }) ) ); -export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { +export const setInviteLinkEnabled = async (enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => + withAuthV2(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { await prisma.org.update({ where: { id: org.id }, data: { inviteLinkEnabled: enabled }, @@ -1290,18 +1145,18 @@ export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Pr return { success: true, }; - }, /* minRequiredRole = */ OrgRole.OWNER) + }) ) ); -export const approveAccountRequest = async (requestId: string, domain: string) => sew(async () => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { +export const approveAccountRequest = async (requestId: string) => sew(async () => + withAuthV2(async ({ org, user, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { const failAuditCallback = async (error: string) => { await auditService.createAudit({ action: "user.join_request_approve_failed", actor: { - id: userId, + id: user.id, type: "user" }, target: { @@ -1355,7 +1210,7 @@ export const approveAccountRequest = async (requestId: string, domain: string) = from: env.EMAIL_FROM_ADDRESS, subject: `Your request to join ${org.name} has been approved`, html, - text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${origin}/${org.domain}`, + text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}/${org.domain}`, }); const failed = result.rejected.concat(result.pending).filter(Boolean); @@ -1369,7 +1224,7 @@ export const approveAccountRequest = async (requestId: string, domain: string) = await auditService.createAudit({ action: "user.join_request_approved", actor: { - id: userId, + id: user.id, type: "user" }, orgId: org.id, @@ -1381,12 +1236,12 @@ export const approveAccountRequest = async (requestId: string, domain: string) = return { success: true, } - }, /* minRequiredRole = */ OrgRole.OWNER) + }) )); -export const rejectAccountRequest = async (requestId: string, domain: string) => sew(() => - withAuth(async (userId) => - withOrgMembership(userId, domain, async ({ org }) => { +export const rejectAccountRequest = async (requestId: string) => sew(() => + withAuthV2(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { const request = await prisma.accountRequest.findUnique({ where: { id: requestId, @@ -1406,30 +1261,28 @@ export const rejectAccountRequest = async (requestId: string, domain: string) => return { success: true, } - }, /* minRequiredRole = */ OrgRole.OWNER) + }) )); -export const getSearchContexts = async (domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const searchContexts = await prisma.searchContext.findMany({ - where: { - orgId: org.id, - }, - include: { - repos: true, - }, - }); +export const getSearchContexts = async () => sew(() => + withOptionalAuthV2(async ({ org, prisma }) => { + const searchContexts = await prisma.searchContext.findMany({ + where: { + orgId: org.id, + }, + include: { + repos: true, + }, + }); - return searchContexts.map((context) => ({ - id: context.id, - name: context.name, - description: context.description ?? undefined, - repoNames: context.repos.map((repo) => repo.name), - })); - }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true - )); + return searchContexts.map((context) => ({ + id: context.id, + name: context.name, + description: context.description ?? undefined, + repoNames: context.repos.map((repo) => repo.name), + })); + })); export const getRepoImage = async (repoId: number): Promise => sew(async () => { return await withOptionalAuthV2(async ({ org, prisma }) => { @@ -1527,9 +1380,9 @@ export const getAnonymousAccessStatus = async (domain: string): Promise => sew(async () => { - return await withAuth(async (userId) => { - return await withOrgMembership(userId, domain, async ({ org }) => { +export const setAnonymousAccessStatus = async (enabled: boolean): Promise => sew(async () => { + return await withAuthV2(async ({ org, role, prisma }) => { + return await withMinimumOrgRole(role, OrgRole.OWNER, async () => { const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); if (!hasAnonymousAccessEntitlement) { const plan = getPlan(); @@ -1557,7 +1410,7 @@ export const setAnonymousAccessStatus = async (domain: string, enabled: boolean) }); return true; - }, /* minRequiredRole = */ OrgRole.OWNER); + }); }); }); diff --git a/packages/web/src/app/[domain]/chat/[id]/page.tsx b/packages/web/src/app/[domain]/chat/[id]/page.tsx index d1c57fe5a..2fd31d7ad 100644 --- a/packages/web/src/app/[domain]/chat/[id]/page.tsx +++ b/packages/web/src/app/[domain]/chat/[id]/page.tsx @@ -104,7 +104,7 @@ export default async function Page(props: PageProps) { const languageModels = await getConfiguredLanguageModelsInfo(); const repos = await getRepos(); - const searchContexts = await getSearchContexts(params.domain); + const searchContexts = await getSearchContexts(); const chatInfo = await getChatInfo({ chatId: params.id }); const chatHistory = session ? await getUserChatHistory() : []; diff --git a/packages/web/src/app/[domain]/chat/page.tsx b/packages/web/src/app/[domain]/chat/page.tsx index f7c98adbb..61b33b33f 100644 --- a/packages/web/src/app/[domain]/chat/page.tsx +++ b/packages/web/src/app/[domain]/chat/page.tsx @@ -27,7 +27,7 @@ interface PageProps { export default async function Page(props: PageProps) { const params = await props.params; const languageModels = await getConfiguredLanguageModelsInfo(); - const searchContexts = await getSearchContexts(params.domain); + const searchContexts = await getSearchContexts(); const allRepos = await getRepos(); const session = await auth(); const chatHistory = session ? await getUserChatHistory() : []; diff --git a/packages/web/src/app/[domain]/components/navigationMenu/index.tsx b/packages/web/src/app/[domain]/components/navigationMenu/index.tsx index 79b671539..49b641f5b 100644 --- a/packages/web/src/app/[domain]/components/navigationMenu/index.tsx +++ b/packages/web/src/app/[domain]/components/navigationMenu/index.tsx @@ -33,7 +33,7 @@ export const NavigationMenu = async ({ throw new ServiceErrorException(repoStats); } - const role = isAuthenticated ? await getCurrentUserRole(domain) : null; + const role = isAuthenticated ? await getCurrentUserRole() : null; if (isServiceError(role)) { throw new ServiceErrorException(role); } @@ -43,7 +43,7 @@ export const NavigationMenu = async ({ return null; } - const joinRequests = await getOrgAccountRequests(domain); + const joinRequests = await getOrgAccountRequests(); if (isServiceError(joinRequests)) { throw new ServiceErrorException(joinRequests); } diff --git a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts index 92c9bb401..61a05b458 100644 --- a/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts +++ b/packages/web/src/app/[domain]/components/searchBar/useSuggestionsData.ts @@ -107,7 +107,7 @@ export const useSuggestionsData = ({ const { data: searchContextSuggestions, isLoading: _isLoadingSearchContexts } = useQuery({ queryKey: ["searchContexts", domain], - queryFn: () => getSearchContexts(domain), + queryFn: () => getSearchContexts(), select: (data): Suggestion[] => { if (isServiceError(data)) { return []; diff --git a/packages/web/src/app/[domain]/repos/[id]/page.tsx b/packages/web/src/app/[domain]/repos/[id]/page.tsx index ca3ab28aa..d16926d12 100644 --- a/packages/web/src/app/[domain]/repos/[id]/page.tsx +++ b/packages/web/src/app/[domain]/repos/[id]/page.tsx @@ -52,7 +52,7 @@ export default async function RepoDetailPage({ params }: { params: Promise<{ id: const repoMetadata = repoMetadataSchema.parse(repo.metadata); - const userRole = await getCurrentUserRole(SINGLE_TENANT_ORG_DOMAIN); + const userRole = await getCurrentUserRole(); if (isServiceError(userRole)) { throw new ServiceErrorException(userRole); } diff --git a/packages/web/src/app/[domain]/repos/layout.tsx b/packages/web/src/app/[domain]/repos/layout.tsx index 675fdf949..4da2c4f08 100644 --- a/packages/web/src/app/[domain]/repos/layout.tsx +++ b/packages/web/src/app/[domain]/repos/layout.tsx @@ -24,7 +24,7 @@ export default async function Layout( throw new ServiceErrorException(repoStats); } - const userRoleInOrg = await getCurrentUserRole(domain); + const userRoleInOrg = await getCurrentUserRole(); return (
diff --git a/packages/web/src/app/[domain]/settings/apiKeys/apiKeysPage.tsx b/packages/web/src/app/[domain]/settings/apiKeys/apiKeysPage.tsx index 0d86a2b8e..628d1d50b 100644 --- a/packages/web/src/app/[domain]/settings/apiKeys/apiKeysPage.tsx +++ b/packages/web/src/app/[domain]/settings/apiKeys/apiKeysPage.tsx @@ -7,7 +7,6 @@ import { Input } from "@/components/ui/input"; import { isServiceError } from "@/lib/utils"; import { Copy, Check, AlertTriangle, Loader2, Plus } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useDomain } from "@/hooks/useDomain"; import { useToast } from "@/components/hooks/use-toast"; import useCaptureEvent from "@/hooks/useCaptureEvent"; import { DataTable } from "@/components/ui/data-table"; @@ -16,7 +15,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export function ApiKeysPage({ canCreateApiKey }: { canCreateApiKey: boolean }) { - const domain = useDomain(); const { toast } = useToast(); const captureEvent = useCaptureEvent(); @@ -33,7 +31,7 @@ export function ApiKeysPage({ canCreateApiKey }: { canCreateApiKey: boolean }) { setIsLoading(true); setError(null); try { - const keys = await getUserApiKeys(domain); + const keys = await getUserApiKeys(); if (isServiceError(keys)) { setError("Failed to load API keys"); toast({ @@ -55,7 +53,7 @@ export function ApiKeysPage({ canCreateApiKey }: { canCreateApiKey: boolean }) { } finally { setIsLoading(false); } - }, [domain, toast]); + }, [toast]); useEffect(() => { loadApiKeys(); @@ -73,7 +71,7 @@ export function ApiKeysPage({ canCreateApiKey }: { canCreateApiKey: boolean }) { setIsCreatingKey(true); try { - const result = await createApiKey(newKeyName.trim(), domain); + const result = await createApiKey(newKeyName.trim()); if (isServiceError(result)) { toast({ title: "Error", diff --git a/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx b/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx index a32bfabba..c219db553 100644 --- a/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx +++ b/packages/web/src/app/[domain]/settings/apiKeys/columns.tsx @@ -4,7 +4,6 @@ import { ColumnDef } from "@tanstack/react-table" import { ArrowUpDown, Key, Trash2 } from "lucide-react" import { Button } from "@/components/ui/button" import { deleteApiKey } from "@/actions" -import { useParams } from "next/navigation" import { AlertDialog, AlertDialogAction, @@ -27,14 +26,13 @@ export type ApiKeyColumnInfo = { // Component for the actions cell to properly use React hooks function ApiKeyActions({ apiKey }: { apiKey: ApiKeyColumnInfo }) { - const params = useParams<{ domain: string }>() const [isPending, setIsPending] = useState(false) const { toast } = useToast() const handleDelete = async () => { setIsPending(true) try { - await deleteApiKey(apiKey.name, params.domain) + await deleteApiKey(apiKey.name) window.location.reload() } catch (error) { console.error("Failed to delete API key", error) diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 857cef673..d9e9f380b 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -70,7 +70,7 @@ export const getSidebarNavItems = async () => withAuthV2(async ({ role }) => { let numJoinRequests: number | undefined; if (role === OrgRole.OWNER) { - const requests = await getOrgAccountRequests(SINGLE_TENANT_ORG_DOMAIN); + const requests = await getOrgAccountRequests(); if (isServiceError(requests)) { throw new ServiceErrorException(requests); } diff --git a/packages/web/src/app/[domain]/settings/license/page.tsx b/packages/web/src/app/[domain]/settings/license/page.tsx index ba955260c..7413db9b8 100644 --- a/packages/web/src/app/[domain]/settings/license/page.tsx +++ b/packages/web/src/app/[domain]/settings/license/page.tsx @@ -75,7 +75,7 @@ export default async function LicensePage(props: LicensePageProps) { ) } - const members = await getOrgMembers(domain); + const members = await getOrgMembers(); if (isServiceError(members)) { throw new ServiceErrorException(members); } diff --git a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx index da248dd94..878d50892 100644 --- a/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/inviteMemberCard.tsx @@ -12,7 +12,6 @@ import { PlusCircleIcon, Loader2, AlertCircle } from "lucide-react"; import { OrgRole } from "@prisma/client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { createInvites } from "@/actions"; -import { useDomain } from "@/hooks/useDomain"; import { isServiceError } from "@/lib/utils"; import { useToast } from "@/components/hooks/use-toast"; import { useRouter } from "next/navigation"; @@ -35,8 +34,7 @@ interface InviteMemberCardProps { export const InviteMemberCard = ({ currentUserRole, seatsAvailable = true }: InviteMemberCardProps) => { const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const domain = useDomain(); - const { toast } = useToast(); + const { toast } = useToast(); const router = useRouter(); const captureEvent = useCaptureEvent(); @@ -54,7 +52,7 @@ export const InviteMemberCard = ({ currentUserRole, seatsAvailable = true }: Inv const onSubmit = useCallback((data: z.infer) => { setIsLoading(true); - createInvites(data.emails.map(e => e.email), domain) + createInvites(data.emails.map(e => e.email)) .then((res) => { if (isServiceError(res)) { toast({ @@ -78,7 +76,7 @@ export const InviteMemberCard = ({ currentUserRole, seatsAvailable = true }: Inv .finally(() => { setIsLoading(false); }); - }, [domain, form, toast, router, captureEvent]); + }, [form, toast, router, captureEvent]); const isDisabled = !seatsAvailable || currentUserRole !== OrgRole.OWNER || isLoading; diff --git a/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx b/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx index 66d096652..89444629b 100644 --- a/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/invitesList.tsx @@ -14,7 +14,6 @@ import { Copy, MoreVertical, Search } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { cancelInvite } from "@/actions"; import { useRouter } from "next/navigation"; -import { useDomain } from "@/hooks/useDomain"; import useCaptureEvent from "@/hooks/useCaptureEvent"; interface Invite { id: string; @@ -34,7 +33,6 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { const [inviteToCancel, setInviteToCancel] = useState(null) const { toast } = useToast(); const router = useRouter(); - const domain = useDomain(); const captureEvent = useCaptureEvent(); const filteredInvites = useMemo(() => { @@ -53,7 +51,7 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { }, [invites, searchQuery, dateSort]); const onCancelInvite = useCallback((inviteId: string) => { - cancelInvite(inviteId, domain) + cancelInvite(inviteId) .then((response) => { if (isServiceError(response)) { toast({ @@ -70,7 +68,7 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => { router.refresh(); } }); - }, [domain, toast, router, captureEvent]); + }, [toast, router, captureEvent]); return (
diff --git a/packages/web/src/app/[domain]/settings/members/components/requestsList.tsx b/packages/web/src/app/[domain]/settings/members/components/requestsList.tsx index f3ae55f31..3b2c35b69 100644 --- a/packages/web/src/app/[domain]/settings/members/components/requestsList.tsx +++ b/packages/web/src/app/[domain]/settings/members/components/requestsList.tsx @@ -13,7 +13,6 @@ import { CheckCircle, Search, XCircle } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { approveAccountRequest, rejectAccountRequest } from "@/actions"; import { useRouter } from "next/navigation"; -import { useDomain } from "@/hooks/useDomain"; import useCaptureEvent from "@/hooks/useCaptureEvent"; interface Request { @@ -36,7 +35,6 @@ export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) = const [requestToAction, setRequestToAction] = useState(null) const { toast } = useToast(); const router = useRouter(); - const domain = useDomain(); const captureEvent = useCaptureEvent(); const filteredRequests = useMemo(() => { @@ -56,7 +54,7 @@ export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) = }, [requests, searchQuery, dateSort]); const onApproveRequest = useCallback((requestId: string) => { - approveAccountRequest(requestId, domain) + approveAccountRequest(requestId) .then((response) => { if (isServiceError(response)) { toast({ @@ -73,10 +71,10 @@ export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) = router.refresh(); } }); - }, [domain, toast, router, captureEvent]); + }, [toast, router, captureEvent]); const onRejectRequest = useCallback((requestId: string) => { - rejectAccountRequest(requestId, domain) + rejectAccountRequest(requestId) .then((response) => { if (isServiceError(response)) { toast({ @@ -93,7 +91,7 @@ export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) = router.refresh(); } }); - }, [domain, toast, router, captureEvent]); + }, [toast, router, captureEvent]); return (
diff --git a/packages/web/src/app/[domain]/settings/members/page.tsx b/packages/web/src/app/[domain]/settings/members/page.tsx index 8a16e5726..4b384d149 100644 --- a/packages/web/src/app/[domain]/settings/members/page.tsx +++ b/packages/web/src/app/[domain]/settings/members/page.tsx @@ -56,17 +56,17 @@ export default async function MembersSettingsPage(props: MembersSettingsPageProp redirect(`/${domain}/settings`); } - const members = await getOrgMembers(domain); + const members = await getOrgMembers(); if (isServiceError(members)) { throw new ServiceErrorException(members); } - const invites = await getOrgInvites(domain); + const invites = await getOrgInvites(); if (isServiceError(invites)) { throw new ServiceErrorException(invites); } - const requests = await getOrgAccountRequests(domain); + const requests = await getOrgAccountRequests(); if (isServiceError(requests)) { throw new ServiceErrorException(requests); } diff --git a/packages/web/src/app/components/anonymousAccessToggle.tsx b/packages/web/src/app/components/anonymousAccessToggle.tsx index 4d079288e..1079f2ed5 100644 --- a/packages/web/src/app/components/anonymousAccessToggle.tsx +++ b/packages/web/src/app/components/anonymousAccessToggle.tsx @@ -3,7 +3,6 @@ import { useState } from "react" import { Switch } from "@/components/ui/switch" import { setAnonymousAccessStatus } from "@/actions" -import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { isServiceError } from "@/lib/utils" import { useToast } from "@/components/hooks/use-toast" @@ -22,7 +21,7 @@ export function AnonymousAccessToggle({ hasAnonymousAccessEntitlement, anonymous const handleToggle = async (checked: boolean) => { setIsLoading(true) try { - const result = await setAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN, checked) + const result = await setAnonymousAccessStatus(checked) if (isServiceError(result)) { toast({ diff --git a/packages/web/src/app/components/inviteLinkToggle.tsx b/packages/web/src/app/components/inviteLinkToggle.tsx index feaef814d..bd610a2e9 100644 --- a/packages/web/src/app/components/inviteLinkToggle.tsx +++ b/packages/web/src/app/components/inviteLinkToggle.tsx @@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { Copy, Check } from "lucide-react" import { useToast } from "@/components/hooks/use-toast" -import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { setInviteLinkEnabled } from "@/actions" import { isServiceError } from "@/lib/utils" @@ -25,7 +24,7 @@ export function InviteLinkToggle({ inviteLinkEnabled, inviteLink }: InviteLinkTo const handleToggle = async (checked: boolean) => { setIsLoading(true) try { - const result = await setInviteLinkEnabled(SINGLE_TENANT_ORG_DOMAIN, checked) + const result = await setInviteLinkEnabled(checked) if (isServiceError(result)) { toast({ diff --git a/packages/web/src/app/components/memberApprovalRequiredToggle.tsx b/packages/web/src/app/components/memberApprovalRequiredToggle.tsx index 671992328..2cb33db43 100644 --- a/packages/web/src/app/components/memberApprovalRequiredToggle.tsx +++ b/packages/web/src/app/components/memberApprovalRequiredToggle.tsx @@ -3,7 +3,6 @@ import { useState } from "react" import { Switch } from "@/components/ui/switch" import { setMemberApprovalRequired } from "@/actions" -import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { isServiceError } from "@/lib/utils" import { useToast } from "@/components/hooks/use-toast" @@ -21,7 +20,7 @@ export function MemberApprovalRequiredToggle({ memberApprovalRequired, onToggleC const handleToggle = async (checked: boolean) => { setIsLoading(true) try { - const result = await setMemberApprovalRequired(SINGLE_TENANT_ORG_DOMAIN, checked) + const result = await setMemberApprovalRequired(checked) if (isServiceError(result)) { toast({ diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts index d7e8b1abd..c25fd84f7 100644 --- a/packages/web/src/app/invite/actions.ts +++ b/packages/web/src/app/invite/actions.ts @@ -1,22 +1,21 @@ "use server"; -import { withAuth } from "@/actions"; import { isServiceError } from "@/lib/utils"; import { orgNotFound, ServiceError } from "@/lib/serviceError"; import { sew } from "@/actions"; import { addUserToOrganization } from "@/lib/authUtils"; -import { prisma } from "@/prisma"; +import { withAuthV2_skipOrgMembershipCheck } from "@/withAuthV2"; import { StatusCodes } from "http-status-codes"; import { ErrorCode } from "@/lib/errorCodes"; export const joinOrganization = async (orgId: number, inviteLinkId?: string) => sew(async () => - withAuth(async (userId) => { + withAuthV2_skipOrgMembershipCheck(async ({ user, prisma }) => { const org = await prisma.org.findUnique({ where: { id: orgId, }, }); - + if (!org) { return orgNotFound(); } @@ -40,7 +39,7 @@ export const joinOrganization = async (orgId: number, inviteLinkId?: string) => } } - const addUserToOrgRes = await addUserToOrganization(userId, org.id); + const addUserToOrgRes = await addUserToOrganization(user.id, org.id); if (isServiceError(addUserToOrgRes)) { return addUserToOrgRes; } diff --git a/packages/web/src/app/onboard/components/completeOnboardingButton.tsx b/packages/web/src/app/onboard/components/completeOnboardingButton.tsx index cd5455a4d..2e871b3df 100644 --- a/packages/web/src/app/onboard/components/completeOnboardingButton.tsx +++ b/packages/web/src/app/onboard/components/completeOnboardingButton.tsx @@ -4,7 +4,6 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { completeOnboarding } from "@/actions" -import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" import { isServiceError } from "@/lib/utils" import { useToast } from "@/components/hooks/use-toast" @@ -17,7 +16,7 @@ export function CompleteOnboardingButton() { setIsLoading(true) try { - const result = await completeOnboarding(SINGLE_TENANT_ORG_DOMAIN) + const result = await completeOnboarding() if (isServiceError(result)) { toast({ diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts index 571207405..624bf4927 100644 --- a/packages/web/src/ee/features/analytics/actions.ts +++ b/packages/web/src/ee/features/analytics/actions.ts @@ -1,26 +1,26 @@ 'use server'; -import { sew, withAuth, withOrgMembership } from "@/actions"; -import { OrgRole } from "@sourcebot/db"; -import { prisma } from "@/prisma"; +import { sew } from "@/actions"; +import { withAuthV2, withMinimumOrgRole } from "@/withAuthV2"; import { ServiceError } from "@/lib/serviceError"; import { AnalyticsResponse, AnalyticsRow } from "./types"; import { env, hasEntitlement } from "@sourcebot/shared"; import { ErrorCode } from "@/lib/errorCodes"; import { StatusCodes } from "http-status-codes"; +import { OrgRole } from "@sourcebot/db"; -export const getAnalytics = async (domain: string, apiKey: string | undefined = undefined): Promise => sew(() => - withAuth((userId, _apiKeyHash) => - withOrgMembership(userId, domain, async ({ org }) => { - if (!hasEntitlement("analytics")) { - return { - statusCode: StatusCodes.FORBIDDEN, - errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, - message: "Analytics is not available in your current plan", - } satisfies ServiceError; - } +export const getAnalytics = async (): Promise => sew(() => + withAuthV2(async ({ org, role, prisma }) => + withMinimumOrgRole(role, OrgRole.OWNER, async () => { + if (!hasEntitlement("analytics")) { + return { + statusCode: StatusCodes.FORBIDDEN, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: "Analytics is not available in your current plan", + } satisfies ServiceError; + } - const rows = await prisma.$queryRaw` + const rows = await prisma.$queryRaw` WITH core AS ( SELECT date_trunc('day', "timestamp") AS day, @@ -171,10 +171,10 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined = select: { timestamp: true }, }); - return { - rows, - retentionDays: env.SOURCEBOT_EE_AUDIT_RETENTION_DAYS, - oldestRecordDate: oldestRecord?.timestamp ?? null, - }; - }, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) -); \ No newline at end of file + return { + rows, + retentionDays: env.SOURCEBOT_EE_AUDIT_RETENTION_DAYS, + oldestRecordDate: oldestRecord?.timestamp ?? null, + }; + })) +); \ No newline at end of file diff --git a/packages/web/src/ee/features/analytics/analyticsContent.tsx b/packages/web/src/ee/features/analytics/analyticsContent.tsx index c40dd2f10..fb9d5c15b 100644 --- a/packages/web/src/ee/features/analytics/analyticsContent.tsx +++ b/packages/web/src/ee/features/analytics/analyticsContent.tsx @@ -396,7 +396,7 @@ export function AnalyticsContent() { error } = useQuery({ queryKey: ["analytics", domain], - queryFn: () => unwrapServiceError(getAnalytics(domain)), + queryFn: () => unwrapServiceError(getAnalytics()), }) const chartColors = useMemo(() => ({ diff --git a/packages/web/src/withAuthV2.ts b/packages/web/src/withAuthV2.ts index e950829e4..137cd4580 100644 --- a/packages/web/src/withAuthV2.ts +++ b/packages/web/src/withAuthV2.ts @@ -10,20 +10,41 @@ import { ErrorCode } from "./lib/errorCodes"; import { getOrgMetadata, isServiceError } from "./lib/utils"; import { hasEntitlement } from "@sourcebot/shared"; -interface OptionalAuthContext { +type OptionalAuthContext = { user?: UserWithAccounts; org: Org; role: OrgRole; prisma: PrismaClient; } -interface RequiredAuthContext { +type RequiredAuthContext = { user: UserWithAccounts; org: Org; role: Exclude; prisma: PrismaClient; } +/** + * Requires a logged-in user but does NOT check org membership. + * Use this for actions where the user may not yet be a member + * of the org (e.g. joining an org, redeeming an invite). + */ +export const withAuthV2_skipOrgMembershipCheck = async (fn: (params: Omit & { role: OrgRole; }) => Promise) => { + const authContext = await getAuthContext(); + + if (isServiceError(authContext)) { + return authContext; + } + + const { user, prisma, org, role } = authContext; + + if (!user) { + return notAuthenticated(); + } + + return fn({ user, prisma, org, role }); +}; + export const withAuthV2 = async (fn: (params: RequiredAuthContext) => Promise) => { const authContext = await getAuthContext();