diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bf1aa885..6c6a33b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Added `GET /api/diff` endpoint for retrieving structured diffs between two git refs ([#1063](https://github.com/sourcebot-dev/sourcebot/pull/1063)) +- Added `GET /api/diff` endpoint for retrieving structured diffs between two git refs [#1063](https://github.com/sourcebot-dev/sourcebot/pull/1063) ### Fixed -- Fixed `GET /api/mcp` hanging with zero bytes by returning `405 Method Not Allowed` per the MCP Streamable HTTP spec ([#1064](https://github.com/sourcebot-dev/sourcebot/pull/1064)) +- Fixed `GET /api/mcp` hanging with zero bytes by returning `405 Method Not Allowed` per the MCP Streamable HTTP spec [#1064](https://github.com/sourcebot-dev/sourcebot/pull/1064) + +### Removed +- Removed "general" settings page with options to change organization name and domain. [#1065](https://github.com/sourcebot-dev/sourcebot/pull/1065) + +### Changed +- Changed the analytics and license settings pages to only be viewable by organization owners. [#1065](https://github.com/sourcebot-dev/sourcebot/pull/1065) ## [4.16.3] - 2026-03-27 diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 4961144a0..60ffe332e 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -28,7 +28,6 @@ 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 { orgDomainSchema, orgNameSchema } from "./lib/schemas"; import { ApiKeyPayload, RepositoryQuery, TenancyMode } from "./lib/types"; import { withAuthV2, withOptionalAuthV2 } from "./withAuthV2"; import { getBrowsePath } from "./app/[domain]/browse/hooks/utils"; @@ -186,54 +185,6 @@ export const withTenancyModeEnforcement = async(mode: TenancyMode, fn: () => } ////// Actions /////// - -export const updateOrgName = async (name: string, domain: string) => sew(() => - withAuth((userId) => - withOrgMembership(userId, domain, async ({ org }) => { - const { success } = orgNameSchema.safeParse(name); - 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: { name }, - }); - - 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 }) => { @@ -1164,18 +1115,6 @@ export const getInviteInfo = async (inviteId: string) => sew(() => } })); -export const checkIfOrgDomainExists = async (domain: string): Promise => sew(() => - withAuth(async () => { - const org = await prisma.org.findFirst({ - where: { - domain, - } - }); - - return !!org; - })); - - export const getOrgMembers = async (domain: string) => sew(() => withAuth(async (userId) => withOrgMembership(userId, domain, async ({ org }) => { diff --git a/packages/web/src/app/[domain]/settings/(general)/components/changeOrgDomainCard.tsx b/packages/web/src/app/[domain]/settings/(general)/components/changeOrgDomainCard.tsx deleted file mode 100644 index 484b84271..000000000 --- a/packages/web/src/app/[domain]/settings/(general)/components/changeOrgDomainCard.tsx +++ /dev/null @@ -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>({ - resolver: zodResolver(formSchema), - defaultValues: { - domain: orgDomain, - }, - }) - const { isSubmitting } = form.formState; - - const onSubmit = useCallback(async (data: z.infer) => { - 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', { - errorCode: 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 ( - <> - - - - Organization URL - - {`Your organization's URL namespace. This is where your organization's Sourcebot instance will be accessible.`} - - -
- - ( - - -
-
{rootDomain}/
- -
-
- -
- )} - /> -
- - - - - - - Are you sure? - - Any links pointing to the current organization URL will no longer work. - - - - Cancel - { - e.preventDefault(); - form.handleSubmit(onSubmit)(e); - setIsDialogOpen(false); - }} - > - Continue - - - - - -
- - -
-
- - - ) -} diff --git a/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx b/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx deleted file mode 100644 index b83eae80a..000000000 --- a/packages/web/src/app/[domain]/settings/(general)/components/changeOrgNameCard.tsx +++ /dev/null @@ -1,107 +0,0 @@ -'use client'; - -import { updateOrgName } from "@/actions"; -import { useToast } from "@/components/hooks/use-toast"; -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 { orgNameSchema } from "@/lib/schemas"; -import { isServiceError } from "@/lib/utils"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { OrgRole } from "@sourcebot/db"; -import { Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useCallback } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; - -const formSchema = z.object({ - name: orgNameSchema, -}) - -interface ChangeOrgNameCardProps { - currentUserRole: OrgRole, - orgName: string, -} - -export function ChangeOrgNameCard({ orgName, currentUserRole }: ChangeOrgNameCardProps) { - const domain = useDomain() - const { toast } = useToast() - const captureEvent = useCaptureEvent(); - const router = useRouter(); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: orgName, - }, - }) - const { isSubmitting } = form.formState; - - const onSubmit = useCallback(async (data: z.infer) => { - const result = await updateOrgName(data.name, domain); - if (isServiceError(result)) { - toast({ - description: `❌ Failed to update organization name. Reason: ${result.message}`, - }) - captureEvent('wa_org_name_updated_fail', { - errorCode: result.errorCode, - }); - } else { - toast({ - description: "✅ Organization name updated successfully", - }); - captureEvent('wa_org_name_updated_success', {}); - router.refresh(); - } - }, [domain, router, toast, captureEvent]); - - return ( - - - - Organization Name - - {`Your organization's visible name within Sourcebot. For example, the name of your company or department.`} - - -
- - ( - - - - - - - )} - /> -
- -
- - -
-
- ) -} - diff --git a/packages/web/src/app/[domain]/settings/(general)/page.tsx b/packages/web/src/app/[domain]/settings/(general)/page.tsx deleted file mode 100644 index c52f1b550..000000000 --- a/packages/web/src/app/[domain]/settings/(general)/page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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<{ - domain: string; - }> -} - -export default async function GeneralSettingsPage(props: GeneralSettingsPageProps) { - const params = await props.params; - - const { - domain - } = params; - - const currentUserRole = await getCurrentUserRole(domain) - if (isServiceError(currentUserRole)) { - throw new ServiceErrorException(currentUserRole); - } - - const org = await getOrgFromDomain(domain) - if (!org) { - throw new ServiceErrorException({ - message: "Failed to fetch organization.", - statusCode: 500, - errorCode: ErrorCode.NOT_FOUND, - }); - } - - const host = (await headers()).get('host') ?? ''; - - return ( -
-
-

General Settings

-
- - - - -
- ) -} - diff --git a/packages/web/src/app/[domain]/settings/analytics/page.tsx b/packages/web/src/app/[domain]/settings/analytics/page.tsx index a542432b3..b5a680611 100644 --- a/packages/web/src/app/[domain]/settings/analytics/page.tsx +++ b/packages/web/src/app/[domain]/settings/analytics/page.tsx @@ -1,19 +1,50 @@ -"use client" - +import { getMe } from "@/actions"; +import { getOrgFromDomain } from "@/data/org"; import { AnalyticsContent } from "@/ee/features/analytics/analyticsContent"; import { AnalyticsEntitlementMessage } from "@/ee/features/analytics/analyticsEntitlementMessage"; -import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { isServiceError } from "@/lib/utils"; +import { OrgRole } from "@sourcebot/db"; +import { hasEntitlement } from "@sourcebot/shared"; +import { redirect } from "next/navigation"; -export default function AnalyticsPage() { - return ; +interface Props { + params: Promise<{ + domain: string; + }> } -function AnalyticsPageContent() { - const hasAnalyticsEntitlement = useHasEntitlement("analytics"); +export default async function AnalyticsPage(props: Props) { + const params = await props.params; + + const { + domain + } = params; + + const org = await getOrgFromDomain(domain); + if (!org) { + throw new Error("Organization not found"); + } + + const me = await getMe(); + if (isServiceError(me)) { + throw new ServiceErrorException(me); + } + + const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; + if (!userRoleInOrg) { + throw new Error("User role not found"); + } + + if (userRoleInOrg !== OrgRole.OWNER) { + redirect(`/${domain}/settings`); + } + + const hasAnalyticsEntitlement = hasEntitlement("analytics"); if (!hasAnalyticsEntitlement) { return ; } return ; -} \ No newline at end of file +} diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index adec18d13..0eb17b83e 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -1,16 +1,17 @@ import React from "react" import { Metadata } from "next" -import { SidebarNav, SidebarNavItem } from "./components/sidebar-nav" +import { SidebarNav } from "./components/sidebar-nav" import { NavigationMenu } from "../components/navigationMenu" import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe"; import { redirect } from "next/navigation"; import { auth } from "@/auth"; import { isServiceError } from "@/lib/utils"; -import { getConnectionStats, getMe, getOrgAccountRequests } from "@/actions"; +import { getConnectionStats, getOrgAccountRequests } from "@/actions"; import { ServiceErrorException } from "@/lib/serviceError"; -import { getOrgFromDomain } from "@/data/org"; import { OrgRole } from "@prisma/client"; import { env, hasEntitlement } from "@sourcebot/shared"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import { withAuthV2 } from "@/withAuthV2"; interface LayoutProps { children: React.ReactNode; @@ -39,87 +40,11 @@ export default async function SettingsLayout( return redirect(`/${domain}`); } - const org = await getOrgFromDomain(domain); - if (!org) { - throw new Error("Organization not found"); + const sidebarNavItems = await getSidebarNavItems(); + if (isServiceError(sidebarNavItems)) { + throw new ServiceErrorException(sidebarNavItems); } - const me = await getMe(); - if (isServiceError(me)) { - throw new ServiceErrorException(me); - } - - const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; - if (!userRoleInOrg) { - throw new Error("User role not found"); - } - - let numJoinRequests: number | undefined; - if (userRoleInOrg === OrgRole.OWNER) { - const requests = await getOrgAccountRequests(domain); - if (isServiceError(requests)) { - throw new ServiceErrorException(requests); - } - numJoinRequests = requests.length; - } - - const connectionStats = await getConnectionStats(); - if (isServiceError(connectionStats)) { - throw new ServiceErrorException(connectionStats); - } - - const sidebarNavItems: SidebarNavItem[] = [ - { - title: "General", - href: `/${domain}/settings`, - }, - ...(IS_BILLING_ENABLED ? [ - { - title: "Billing", - href: `/${domain}/settings/billing`, - } - ] : []), - ...(userRoleInOrg === OrgRole.OWNER ? [ - { - title: "Access", - href: `/${domain}/settings/access`, - } - ] : []), - ...(userRoleInOrg === OrgRole.OWNER ? [{ - title: "Members", - isNotificationDotVisible: numJoinRequests !== undefined && numJoinRequests > 0, - href: `/${domain}/settings/members`, - }] : []), - ...(userRoleInOrg === OrgRole.OWNER ? [ - { - title: "Connections", - href: `/${domain}/settings/connections`, - hrefRegex: `/${domain}/settings/connections(/[^/]+)?$`, - isNotificationDotVisible: connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0, - } - ] : []), - ...(env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'false' || userRoleInOrg === OrgRole.OWNER ? [ - { - title: "API Keys", - href: `/${domain}/settings/apiKeys`, - } - ] : []), - { - title: "Analytics", - href: `/${domain}/settings/analytics`, - }, - ...(hasEntitlement("sso") ? [ - { - title: "Linked Accounts", - href: `/${domain}/settings/linked-accounts`, - } - ] : []), - { - title: "License", - href: `/${domain}/settings/license`, - } - ] - return (
@@ -142,3 +67,71 @@ export default async function SettingsLayout( ) } +export const getSidebarNavItems = async () => + withAuthV2(async ({ role }) => { + let numJoinRequests: number | undefined; + if (role === OrgRole.OWNER) { + const requests = await getOrgAccountRequests(SINGLE_TENANT_ORG_DOMAIN); + if (isServiceError(requests)) { + throw new ServiceErrorException(requests); + } + numJoinRequests = requests.length; + } + + const connectionStats = await getConnectionStats(); + if (isServiceError(connectionStats)) { + throw new ServiceErrorException(connectionStats); + } + + return [ + ...(IS_BILLING_ENABLED ? [ + { + title: "Billing", + href: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/billing`, + } + ] : []), + ...(role === OrgRole.OWNER ? [ + { + title: "Access", + href: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/access`, + } + ] : []), + ...(role === OrgRole.OWNER ? [{ + title: "Members", + isNotificationDotVisible: numJoinRequests !== undefined && numJoinRequests > 0, + href: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/members`, + }] : []), + ...(role === OrgRole.OWNER ? [ + { + title: "Connections", + href: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections`, + hrefRegex: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/connections(/[^/]+)?$`, + isNotificationDotVisible: connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0, + } + ] : []), + ...(env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'false' || role === OrgRole.OWNER ? [ + { + title: "API Keys", + href: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/apiKeys`, + } + ] : []), + ...(role === OrgRole.OWNER ? [ + { + title: "Analytics", + href: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/analytics`, + }, + ] : []), + ...(hasEntitlement("sso") ? [ + { + title: "Linked Accounts", + href: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/linked-accounts`, + } + ] : []), + ...(role === OrgRole.OWNER ? [ + { + title: "License", + href: `/${SINGLE_TENANT_ORG_DOMAIN}/settings/license`, + } + ] : []), + ] + }); \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/license/page.tsx b/packages/web/src/app/[domain]/settings/license/page.tsx index 7a3a0240d..ba955260c 100644 --- a/packages/web/src/app/[domain]/settings/license/page.tsx +++ b/packages/web/src/app/[domain]/settings/license/page.tsx @@ -1,9 +1,12 @@ import { getLicenseKey, getEntitlements, getPlan, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared"; import { Button } from "@/components/ui/button"; import { Info, Mail } from "lucide-react"; -import { getOrgMembers } from "@/actions"; +import { getMe, getOrgMembers } from "@/actions"; import { isServiceError } from "@/lib/utils"; import { ServiceErrorException } from "@/lib/serviceError"; +import { getOrgFromDomain } from "@/data/org"; +import { OrgRole } from "@sourcebot/db"; +import { redirect } from "next/navigation"; interface LicensePageProps { params: Promise<{ @@ -18,6 +21,25 @@ export default async function LicensePage(props: LicensePageProps) { domain } = params; + const org = await getOrgFromDomain(domain); + if (!org) { + throw new Error("Organization not found"); + } + + const me = await getMe(); + if (isServiceError(me)) { + throw new ServiceErrorException(me); + } + + const userRoleInOrg = me.memberships.find((membership) => membership.id === org.id)?.role; + if (!userRoleInOrg) { + throw new Error("User role not found"); + } + + if (userRoleInOrg !== OrgRole.OWNER) { + redirect(`/${domain}/settings`); + } + const licenseKey = getLicenseKey(); const entitlements = getEntitlements(); const plan = getPlan(); diff --git a/packages/web/src/app/[domain]/settings/page.tsx b/packages/web/src/app/[domain]/settings/page.tsx new file mode 100644 index 000000000..a6f3ab762 --- /dev/null +++ b/packages/web/src/app/[domain]/settings/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; +import { getSidebarNavItems } from "./layout"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { auth } from "@/auth"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; + +export default async function SettingsPage() { + const session = await auth(); + if (!session) { + return redirect(`/${SINGLE_TENANT_ORG_DOMAIN}`); + } + + const items = await getSidebarNavItems(); + if (isServiceError(items)) { + throw new ServiceErrorException(items); + } + return redirect(items[0].href); +} diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index fb5e26d95..3abfe73b0 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -1,6 +1,4 @@ -import { checkIfOrgDomainExists } from "@/actions"; import { z } from "zod"; -import { isServiceError } from "./utils"; import { CodeHostType } from "@sourcebot/db"; export const secretCreateRequestSchema = z.object({ @@ -39,37 +37,6 @@ export const verifyCredentialsRequestSchema = z.object({ password: z.string().min(8), }); -export const orgNameSchema = z.string().min(2, { message: "Organization name must be at least 3 characters long." }); - -export const orgDomainSchema = z.string() - .min(2, { message: "Url must be at least 3 characters long." }) - .max(50, { message: "Url must be at most 50 characters long." }) - .regex(/^[a-z][a-z-]*[a-z]$/, { - message: "Url must start and end with a letter, and can only contain lowercase letters and dashes.", - }) - .refine((domain) => { - const reserved = [ - 'api', - 'login', - 'signup', - 'onboard', - 'redeem', - 'account', - 'settings', - 'staging', - 'support', - 'docs', - 'blog', - 'contact', - 'status' - ]; - return !reserved.includes(domain); - }, "This url is reserved for internal use.") - .refine(async (domain) => { - const doesDomainExist = await checkIfOrgDomainExists(domain); - return isServiceError(doesDomainExist) || !doesDomainExist; - }, "This url is already taken."); - export const getVersionResponseSchema = z.object({ version: z.string(), });