migrate src/actions.ts to withAuthV2

This commit is contained in:
bkellam 2025-09-22 16:57:58 -07:00
parent 66c9ec044e
commit 6abe7a40a5
7 changed files with 560 additions and 720 deletions

View file

@ -4,7 +4,7 @@ import { getAuditService } from "@/ee/features/audit/factory";
import { env } from "@/env.mjs";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
import { ErrorCode } from "@/lib/errorCodes";
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, unexpectedError } from "@/lib/serviceError";
import { CodeHostType, getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils";
import { prisma } from "@/prisma";
import { render } from "@react-email/components";
@ -38,9 +38,10 @@ 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, SEARCH_MODE_COOKIE_NAME, SINGLE_TENANT_ORG_DOMAIN, SOURCEBOT_GUEST_USER_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants";
import { orgDomainSchema, orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { orgNameSchema, repositoryQuerySchema } from "./lib/schemas";
import { ApiKeyPayload, TenancyMode } from "./lib/types";
import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2";
import { withMinimumOrgRole } from "./withMinimumOrgRole";
const ajv = new Ajv({
validateFormats: false,
@ -187,40 +188,15 @@ export const withTenancyModeEnforcement = async<T>(mode: TenancyMode, fn: () =>
////// Actions ///////
export const createOrg = async (name: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
withTenancyModeEnforcement('multi', () =>
withAuth(async (userId) => {
const org = await prisma.org.create({
data: {
name,
domain,
members: {
create: {
role: "OWNER",
user: {
connect: {
id: userId,
}
}
}
}
}
});
return {
id: org.id,
}
})));
export const updateOrgName = async (name: string, domain: string) => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
export const updateOrgName = async (name: string) => sew(() =>
withAuthV2(({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const { success } = orgNameSchema.safeParse(name);
if (!success) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "Invalid organization url",
message: "Invalid organization name",
} satisfies ServiceError;
}
@ -232,36 +208,10 @@ export const updateOrgName = async (name: string, domain: string) => sew(() =>
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));
export const updateOrgDomain = async (newDomain: string, existingDomain: string) => sew(() =>
withTenancyModeEnforcement('multi', () =>
withAuth((userId) =>
withOrgMembership(userId, existingDomain, async ({ org }) => {
const { success } = await orgDomainSchema.safeParseAsync(newDomain);
if (!success) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_REQUEST_BODY,
message: "Invalid organization url",
} satisfies ServiceError;
}
await prisma.org.update({
where: { id: org.id },
data: { domain: newDomain },
});
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
)));
})));
export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
// If billing is not enabled, we can just mark the org as onboarded.
if (!IS_BILLING_ENABLED) {
await prisma.org.update({
@ -291,12 +241,10 @@ export const completeOnboarding = async (domain: string): Promise<{ success: boo
return {
success: true,
}
})
));
}));
export const getSecrets = async (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const secrets = await prisma.secret.findMany({
where: {
orgId: org.id,
@ -311,11 +259,10 @@ export const getSecrets = async (domain: string): Promise<{ createdAt: Date; key
key: secret.key,
createdAt: secret.createdAt,
}));
})));
}));
export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const encrypted = encrypt(value);
const existingSecret = await prisma.secret.findUnique({
where: {
@ -343,11 +290,10 @@ export const createSecret = async (key: string, value: string, domain: string):
return {
success: true,
}
})));
}));
export const checkIfSecretExists = async (key: string, domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const secret = await prisma.secret.findUnique({
where: {
orgId_key: {
@ -358,11 +304,10 @@ export const checkIfSecretExists = async (key: string, domain: string): Promise<
});
return !!secret;
})));
}));
export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
await prisma.secret.delete({
where: {
orgId_key: {
@ -375,7 +320,7 @@ export const deleteSecret = async (key: string, domain: string): Promise<{ succe
return {
success: true,
}
})));
}));
export const verifyApiKey = async (apiKeyPayload: ApiKeyPayload): Promise<{ apiKey: ApiKey } | ServiceError> => sew(async () => {
const parts = apiKeyPayload.apiKey.split("-");
@ -431,8 +376,8 @@ 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 }) => {
withAuthV2(async ({ user, org, prisma }) => {
const userId = user.id;
const existingApiKey = await prisma.apiKey.findFirst({
where: {
createdById: userId,
@ -490,11 +435,11 @@ export const createApiKey = async (name: string, domain: string): Promise<{ key:
return {
key,
}
})));
}));
export const deleteApiKey = async (name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ user, org, prisma }) => {
const userId = user.id;
const apiKey = await prisma.apiKey.findFirst({
where: {
name,
@ -551,11 +496,11 @@ export const deleteApiKey = async (name: string, domain: string): Promise<{ succ
return {
success: true,
}
})));
}));
export const getUserApiKeys = async (domain: string): Promise<{ name: string; createdAt: Date; lastUsedAt: Date | null }[] | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ user, org, prisma }) => {
const userId = user.id;
const apiKeys = await prisma.apiKey.findMany({
where: {
orgId: org.id,
@ -571,11 +516,10 @@ export const getUserApiKeys = async (domain: string): Promise<{ name: string; cr
createdAt: apiKey.createdAt,
lastUsedAt: apiKey.lastUsedAt,
}));
})));
}));
export const getConnections = async (domain: string, filter: { status?: ConnectionSyncStatus[] } = {}) => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const connections = await prisma.connection.findMany({
where: {
orgId: org.id,
@ -606,12 +550,10 @@ export const getConnections = async (domain: string, filter: { status?: Connecti
repoIndexingStatus: repo.repoIndexingStatus,
})),
}));
})
));
}));
export const getConnectionInfo = async (connectionId: number, domain: string) => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const connection = await prisma.connection.findUnique({
where: {
id: connectionId,
@ -636,7 +578,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
syncedAt: connection.syncedAt ?? undefined,
numLinkedRepos: connection.repos.length,
}
})));
}));
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
@ -730,8 +672,8 @@ export const getRepoInfoByName = async (repoName: string) => sew(() =>
}));
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
if (env.CONFIG_PATH !== undefined) {
return {
statusCode: StatusCodes.BAD_REQUEST,
@ -774,8 +716,7 @@ export const createConnection = async (name: string, type: CodeHostType, connect
return {
id: connection.id,
}
}, OrgRole.OWNER)
));
})));
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
@ -914,8 +855,8 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
}));
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const connection = await getConnection(connectionId, org.id);
if (!connection) {
return notFound();
@ -951,12 +892,11 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st
return {
success: true,
}
}, OrgRole.OWNER)
));
})));
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const connection = await getConnection(connectionId, org.id);
if (!connection) {
return notFound();
@ -991,12 +931,10 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number
return {
success: true,
}
}, OrgRole.OWNER)
));
})));
export const flagConnectionForSync = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const connection = await getConnection(connectionId, org.id);
if (!connection || connection.orgId !== org.id) {
return notFound();
@ -1014,8 +952,7 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
return {
success: true,
}
})
));
}));
export const flagReposForIndex = async (repoIds: number[]) => sew(() =>
withAuthV2(async ({ org, prisma }) => {
@ -1035,8 +972,8 @@ export const flagReposForIndex = async (repoIds: number[]) => sew(() =>
}));
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const connection = await getConnection(connectionId, org.id);
if (!connection) {
return notFound();
@ -1052,19 +989,17 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr
return {
success: true,
}
}, OrgRole.OWNER)
));
})));
export const getCurrentUserRole = async (domain: string): Promise<OrgRole | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ userRole }) => {
return userRole;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
));
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 }) => {
withAuthV2(({ user, org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const userId = user.id;
const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "user.invite_failed",
@ -1083,10 +1018,6 @@ 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);
if (!hasAvailability) {
@ -1238,12 +1169,11 @@ 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 }) => {
withAuthV2(({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
@ -1264,21 +1194,19 @@ export const cancelInvite = async (inviteId: string, domain: string): Promise<{
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));
})));
export const getOrgInviteId = async (domain: string) => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
return org.inviteLinkId;
}, /* 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: {
@ -1289,16 +1217,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,
@ -1429,9 +1357,9 @@ export const getInviteInfo = async (inviteId: string) => sew(() =>
}));
export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
const currentUserId = userId;
withAuthV2(({ user, org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const currentUserId = user.id;
const failAuditCallback = async (error: string) => {
await auditService.createAudit({
@ -1521,11 +1449,10 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));
})));
export const checkIfOrgDomainExists = async (domain: string): Promise<boolean | ServiceError> => sew(() =>
withAuth(async () => {
withAuthV2(async ({ prisma }) => {
const org = await prisma.org.findFirst({
where: {
domain,
@ -1536,8 +1463,8 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
}));
export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
@ -1572,13 +1499,12 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));
})));
export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org, userRole }) => {
if (userRole === OrgRole.OWNER) {
withAuthV2(async ({ user, org, prisma, role }) => {
const userId = user.id;
if (role === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
@ -1607,13 +1533,12 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
return {
success: true,
}
})
));
}));
export const getOrgMembership = async (domain: string) => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ user, org, prisma }) => {
const userId = user.id;
const membership = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
@ -1628,12 +1553,10 @@ export const getOrgMembership = async (domain: string) => sew(() =>
}
return membership;
})
));
}));
export const getOrgMembers = async (domain: string) => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const members = await prisma.userToOrg.findMany({
where: {
orgId: org.id,
@ -1654,12 +1577,10 @@ export const getOrgMembers = async (domain: string) => sew(() =>
role: member.role,
joinedAt: member.joinedAt,
}));
})
));
}));
export const getOrgInvites = async (domain: string) => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const invites = await prisma.invite.findMany({
where: {
orgId: org.id,
@ -1671,12 +1592,10 @@ export const getOrgInvites = async (domain: string) => sew(() =>
email: invite.recipientEmail,
createdAt: invite.createdAt,
}));
})
));
}));
export const getOrgAccountRequests = async (domain: string) => sew(() =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma }) => {
const requests = await prisma.accountRequest.findMany({
where: {
orgId: org.id,
@ -1692,8 +1611,7 @@ export const getOrgAccountRequests = async (domain: string) => sew(() =>
createdAt: request.createdAt,
name: request.requestedBy.name ?? undefined,
}));
})
));
}));
export const createAccountRequest = async (userId: string, domain: string) => sew(async () => {
const user = await prisma.user.findUnique({
@ -1812,8 +1730,8 @@ export const getMemberApprovalRequired = async (domain: string): Promise<boolean
});
export const setMemberApprovalRequired = async (domain: string, required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
await prisma.org.update({
where: { id: org.id },
data: { memberApprovalRequired: required },
@ -1822,7 +1740,7 @@ export const setMemberApprovalRequired = async (domain: string, required: boolea
return {
success: true,
};
}, /* minRequiredRole = */ OrgRole.OWNER)
})
)
);
@ -1841,8 +1759,8 @@ export const getInviteLinkEnabled = async (domain: string): Promise<boolean | Se
});
export const setInviteLinkEnabled = async (domain: string, enabled: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () =>
withAuth(async (userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuthV2(async ({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
await prisma.org.update({
where: { id: org.id },
data: { inviteLinkEnabled: enabled },
@ -1851,13 +1769,14 @@ 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 }) => {
withAuthV2(async ({ user, org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const userId = user.id;
const failAuditCallback = async (error: string) => {
await auditService.createAudit({
action: "user.join_request_approve_failed",
@ -1943,12 +1862,11 @@ 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 }) => {
withAuthV2(async ({ org, prisma, role }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const request = await prisma.accountRequest.findUnique({
where: {
id: requestId,
@ -1968,8 +1886,7 @@ export const rejectAccountRequest = async (requestId: string, domain: string) =>
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
));
})));
export const dismissMobileUnsupportedSplashScreen = async () => sew(async () => {
await (await cookies()).set(MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, 'true');
@ -1977,8 +1894,7 @@ export const dismissMobileUnsupportedSplashScreen = async () => sew(async () =>
});
export const getSearchContexts = async (domain: string) => sew(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
withOptionalAuthV2(async ({ org, prisma }) => {
const searchContexts = await prisma.searchContext.findMany({
where: {
orgId: org.id,
@ -1994,8 +1910,7 @@ export const getSearchContexts = async (domain: string) => sew(() =>
description: context.description ?? undefined,
repoNames: context.repos.map((repo) => repo.name),
}));
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true
));
}));
export const getRepoImage = async (repoId: number): Promise<ArrayBuffer | ServiceError> => sew(async () => {
return await withOptionalAuthV2(async ({ org, prisma }) => {
@ -2094,8 +2009,8 @@ export const getAnonymousAccessStatus = async (domain: string): Promise<boolean
});
export const setAnonymousAccessStatus = async (domain: string, enabled: boolean): Promise<ServiceError | boolean> => sew(async () => {
return await withAuth(async (userId) => {
return await withOrgMembership(userId, domain, async ({ org }) => {
return await withAuthV2(async ({ org, prisma, role }) => {
return await withMinimumOrgRole(role, OrgRole.OWNER, async () => {
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
if (!hasAnonymousAccessEntitlement) {
const plan = getPlan();
@ -2123,7 +2038,7 @@ export const setAnonymousAccessStatus = async (domain: string, enabled: boolean)
});
return true;
}, /* minRequiredRole = */ OrgRole.OWNER);
});
});
});

View file

@ -1,138 +0,0 @@
'use client';
import { updateOrgDomain } from "@/actions";
import { useToast } from "@/components/hooks/use-toast";
import { AlertDialog, AlertDialogFooter, AlertDialogHeader, AlertDialogContent, AlertDialogAction, AlertDialogCancel, AlertDialogDescription, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useDomain } from "@/hooks/useDomain";
import { orgDomainSchema } from "@/lib/schemas";
import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { OrgRole } from "@sourcebot/db";
import { Loader2, TriangleAlert } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
const formSchema = z.object({
domain: orgDomainSchema,
})
interface ChangeOrgDomainCardProps {
currentUserRole: OrgRole,
orgDomain: string,
rootDomain: string,
}
export function ChangeOrgDomainCard({ orgDomain, currentUserRole, rootDomain }: ChangeOrgDomainCardProps) {
const domain = useDomain()
const { toast } = useToast()
const captureEvent = useCaptureEvent();
const router = useRouter();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
domain: orgDomain,
},
})
const { isSubmitting } = form.formState;
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
const result = await updateOrgDomain(data.domain, domain);
if (isServiceError(result)) {
toast({
description: `❌ Failed to update organization url. Reason: ${result.message}`,
})
captureEvent('wa_org_domain_updated_fail', {
error: result.errorCode,
});
} else {
toast({
description: "✅ Organization url updated successfully",
});
captureEvent('wa_org_domain_updated_success', {});
router.replace(`/${data.domain}/settings`);
}
}, [domain, router, toast, captureEvent]);
return (
<>
<Card className="w-full">
<CardHeader className="flex flex-col gap-4">
<CardTitle className="flex items-center gap-2">
Organization URL
</CardTitle>
<CardDescription>{`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center w-full">
<div className="flex-shrink-0 text-sm text-muted-foreground bg-backgroundSecondary rounded-md rounded-r-none border border-r-0 px-3 py-[9px]">{rootDomain}/</div>
<Input
placeholder={orgDomain}
{...field}
disabled={currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization url" : undefined}
className="flex-1 rounded-l-none max-w-xs"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogTrigger asChild>
<Button
disabled={isSubmitting || currentUserRole !== OrgRole.OWNER}
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the organization url" : undefined}
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2"><TriangleAlert className="h-4 w-4 text-destructive" /> Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
Any links pointing to the current organization URL will <strong>no longer work</strong>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
form.handleSubmit(onSubmit)(e);
setIsDialogOpen(false);
}}
>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</form>
</Form>
</CardContent>
</Card>
</>
)
}

View file

@ -7,7 +7,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useDomain } from "@/hooks/useDomain";
import { orgNameSchema } from "@/lib/schemas";
import { isServiceError } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
@ -28,7 +27,6 @@ interface ChangeOrgNameCardProps {
}
export function ChangeOrgNameCard({ orgName, currentUserRole }: ChangeOrgNameCardProps) {
const domain = useDomain()
const { toast } = useToast()
const captureEvent = useCaptureEvent();
const router = useRouter();
@ -42,7 +40,7 @@ export function ChangeOrgNameCard({ orgName, currentUserRole }: ChangeOrgNameCar
const { isSubmitting } = form.formState;
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
const result = await updateOrgName(data.name, domain);
const result = await updateOrgName(data.name);
if (isServiceError(result)) {
toast({
description: `❌ Failed to update organization name. Reason: ${result.message}`,
@ -57,7 +55,7 @@ export function ChangeOrgNameCard({ orgName, currentUserRole }: ChangeOrgNameCar
captureEvent('wa_org_name_updated_success', {});
router.refresh();
}
}, [domain, router, toast, captureEvent]);
}, [router, toast, captureEvent]);
return (
<Card>

View file

@ -2,10 +2,8 @@ import { ChangeOrgNameCard } from "./components/changeOrgNameCard";
import { isServiceError } from "@/lib/utils";
import { getCurrentUserRole } from "@/actions";
import { getOrgFromDomain } from "@/data/org";
import { ChangeOrgDomainCard } from "./components/changeOrgDomainCard";
import { ServiceErrorException } from "@/lib/serviceError";
import { ErrorCode } from "@/lib/errorCodes";
import { headers } from "next/headers";
interface GeneralSettingsPageProps {
params: Promise<{
@ -34,8 +32,6 @@ export default async function GeneralSettingsPage(props: GeneralSettingsPageProp
});
}
const host = (await headers()).get('host') ?? '';
return (
<div className="flex flex-col gap-6">
<div>
@ -46,12 +42,6 @@ export default async function GeneralSettingsPage(props: GeneralSettingsPageProp
orgName={org.name}
currentUserRole={currentUserRole}
/>
<ChangeOrgDomainCard
orgDomain={org.domain}
currentUserRole={currentUserRole}
rootDomain={host}
/>
</div>
)
}

View file

@ -169,33 +169,3 @@ const getVerifiedApiObject = async (apiKeyString: string): Promise<ApiKey | unde
return apiKey;
}
export const withMinimumOrgRole = async <T>(
userRole: OrgRole,
minRequiredRole: OrgRole = OrgRole.MEMBER,
fn: () => Promise<T>,
) => {
const getAuthorizationPrecedence = (role: OrgRole): number => {
switch (role) {
case OrgRole.GUEST:
return 0;
case OrgRole.MEMBER:
return 1;
case OrgRole.OWNER:
return 2;
}
};
if (
getAuthorizationPrecedence(userRole) < 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();
}

View file

@ -0,0 +1,71 @@
import { expect, test, vi, describe } from 'vitest';
import { StatusCodes } from 'http-status-codes';
import { OrgRole } from '@sourcebot/db';
import { withMinimumOrgRole } from './withMinimumOrgRole';
import { ErrorCode } from './lib/errorCodes';
describe('withMinimumOrgRole', () => {
test('should execute function when user has sufficient permissions', async () => {
const mockFn = vi.fn().mockResolvedValue('success');
const result = await withMinimumOrgRole(
OrgRole.OWNER,
OrgRole.MEMBER,
mockFn
);
expect(mockFn).toHaveBeenCalledOnce();
expect(result).toBe('success');
});
test('should return forbidden error when user has insufficient permissions', async () => {
const mockFn = vi.fn().mockResolvedValue('success');
const result = await withMinimumOrgRole(
OrgRole.MEMBER,
OrgRole.OWNER,
mockFn
);
expect(mockFn).not.toHaveBeenCalled();
expect(result).toEqual({
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "You do not have sufficient permissions to perform this action.",
});
});
test('should respect role hierarchy: OWNER > MEMBER > GUEST', async () => {
const mockFn = vi.fn().mockResolvedValue('success');
// Test OWNER can access MEMBER-required functions
const ownerResult = await withMinimumOrgRole(
OrgRole.OWNER,
OrgRole.MEMBER,
mockFn
);
expect(ownerResult).toBe('success');
// Test MEMBER can access MEMBER-required functions
const memberResult = await withMinimumOrgRole(
OrgRole.MEMBER,
OrgRole.MEMBER,
mockFn
);
expect(memberResult).toBe('success');
// Test GUEST cannot access MEMBER-required functions
const guestResult = await withMinimumOrgRole(
OrgRole.GUEST,
OrgRole.MEMBER,
mockFn
);
expect(guestResult).toEqual({
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "You do not have sufficient permissions to perform this action.",
});
expect(mockFn).toHaveBeenCalledTimes(2); // Only successful calls
});
});

View file

@ -0,0 +1,34 @@
import { StatusCodes } from "http-status-codes";
import { OrgRole } from "@sourcebot/db";
import { ErrorCode } from "./lib/errorCodes";
import { ServiceError } from "./lib/serviceError";
export const withMinimumOrgRole = async <T>(
userRole: OrgRole,
minRequiredRole: OrgRole = OrgRole.MEMBER,
fn: () => Promise<T>,
) => {
const getAuthorizationPrecedence = (role: OrgRole): number => {
switch (role) {
case OrgRole.GUEST:
return 0;
case OrgRole.MEMBER:
return 1;
case OrgRole.OWNER:
return 2;
}
};
if (
getAuthorizationPrecedence(userRole) < 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();
}