diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 84ff76be3..08d0e2030 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1810,6 +1810,55 @@ export const setAnonymousAccessStatus = async (domain: string, enabled: boolean) }); }); +export const getUpgradeToastEnabled = async (domain: string): Promise => sew(async () => { + const org = await getOrgFromDomain(domain); + if (!org) { + return { + statusCode: StatusCodes.NOT_FOUND, + errorCode: ErrorCode.NOT_FOUND, + message: "Organization not found", + } satisfies ServiceError; + } + + if (org.metadata === null) { + return true; + } + + const orgMetadata = getOrgMetadata(org); + if (!orgMetadata) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INVALID_ORG_METADATA, + message: "Invalid organization metadata", + } satisfies ServiceError; + } + + return orgMetadata.upgradeToastEnabled ?? true; +}); + +export const setUpgradeToastEnabled = async (domain: string, enabled: boolean): Promise => sew(async () => { + return await withAuth(async (userId) => { + return await withOrgMembership(userId, domain, async ({ org }) => { + const currentMetadata = getOrgMetadata(org); + const mergedMetadata = { + ...(currentMetadata ?? {}), + upgradeToastEnabled: enabled, + }; + + await prisma.org.update({ + where: { + id: org.id, + }, + data: { + metadata: mergedMetadata, + }, + }); + + return true; + }, OrgRole.OWNER); + }); +}); + export const setAgenticSearchTutorialDismissedCookie = async (dismissed: boolean) => sew(async () => { const cookieStore = await cookies(); cookieStore.set(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, dismissed ? "true" : "false", { diff --git a/packages/web/src/app/[domain]/layout.tsx b/packages/web/src/app/[domain]/layout.tsx index de506b400..b576c654b 100644 --- a/packages/web/src/app/[domain]/layout.tsx +++ b/packages/web/src/app/[domain]/layout.tsx @@ -18,13 +18,14 @@ import { SubmitJoinRequest } from "./components/submitJoinRequest"; import { hasEntitlement } from "@sourcebot/shared"; import { env } from "@sourcebot/shared"; import { GcpIapAuth } from "./components/gcpIapAuth"; -import { getAnonymousAccessStatus, getMemberApprovalRequired } from "@/actions"; +import { getAnonymousAccessStatus, getMemberApprovalRequired, getUpgradeToastEnabled } from "@/actions"; import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { GitHubStarToast } from "./components/githubStarToast"; import { UpgradeToast } from "./components/upgradeToast"; import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions"; import { LinkAccounts } from "@/ee/features/permissionSyncing/components/linkAccounts"; +import { OrgRole } from "@sourcebot/db"; interface LayoutProps { children: React.ReactNode, @@ -62,6 +63,10 @@ export default async function Layout(props: LayoutProps) { return status; })(); + const upgradeToastSetting = await getUpgradeToastEnabled(domain); + const upgradeToastEnabled = isServiceError(upgradeToastSetting) ? true : upgradeToastSetting; + let userRole: OrgRole | null = null; + // If the user is authenticated, we must check if they're a member of the org if (session) { const membership = await prisma.userToOrg.findUnique({ @@ -104,6 +109,8 @@ export default async function Layout(props: LayoutProps) { } } } + + userRole = membership.role; } else { // If the user isn't authenticated and anonymous access isn't enabled, we need to redirect them to the login page. if (!anonymousAccessEnabled) { @@ -142,7 +149,7 @@ export default async function Layout(props: LayoutProps) { ) } - + const hasUnlinkedProviders = linkedAccountProviderStates.some(state => state.isLinked === false); if (hasUnlinkedProviders) { const cookieStore = await cookies(); @@ -188,12 +195,13 @@ export default async function Layout(props: LayoutProps) { ) } + return ( {children} - + {(upgradeToastEnabled || userRole === OrgRole.OWNER) ? : null} ) } \ No newline at end of file diff --git a/packages/web/src/app/components/organizationAccessSettings.tsx b/packages/web/src/app/components/organizationAccessSettings.tsx index 7e826c4b7..28d7bed4e 100644 --- a/packages/web/src/app/components/organizationAccessSettings.tsx +++ b/packages/web/src/app/components/organizationAccessSettings.tsx @@ -1,6 +1,7 @@ import { createInviteLink } from "@/lib/utils" import { getBaseUrl } from "@/lib/utils.server" import { AnonymousAccessToggle } from "./anonymousAccessToggle" +import { UpgradeToastToggle } from "./upgradeToastToggle" import { OrganizationAccessSettingsWrapper } from "./organizationAccessSettingsWrapper" import { getOrgFromDomain } from "@/data/org" import { getOrgMetadata } from "@/lib/utils" @@ -17,6 +18,7 @@ export async function OrganizationAccessSettings() { const metadata = getOrgMetadata(org); const anonymousAccessEnabled = metadata?.anonymousAccessEnabled ?? false; + const upgradeToastEnabled = metadata?.upgradeToastEnabled ?? true; const headersList = await headers(); const baseUrl = getBaseUrl(headersList); @@ -39,6 +41,11 @@ export async function OrganizationAccessSettings() { inviteLinkEnabled={org.inviteLinkEnabled} inviteLink={inviteLink} /> + + + ) } \ No newline at end of file diff --git a/packages/web/src/app/components/upgradeToastToggle.tsx b/packages/web/src/app/components/upgradeToastToggle.tsx new file mode 100644 index 000000000..f6920ce63 --- /dev/null +++ b/packages/web/src/app/components/upgradeToastToggle.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useState } from "react" +import { Switch } from "@/components/ui/switch" +import { setUpgradeToastEnabled } from "@/actions" +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants" +import { isServiceError } from "@/lib/utils" +import { useToast } from "@/components/hooks/use-toast" + +interface UpgradeToastToggleProps { + upgradeToastEnabled: boolean + onToggleChange?: (checked: boolean) => void +} + +export function UpgradeToastToggle({ upgradeToastEnabled, onToggleChange }: UpgradeToastToggleProps) { + const [enabled, setEnabled] = useState(upgradeToastEnabled) + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + const result = await setUpgradeToastEnabled(SINGLE_TENANT_ORG_DOMAIN, checked) + + if (isServiceError(result)) { + toast({ + title: "Error", + description: result.message || "Failed to update upgrade notification setting", + variant: "destructive", + }) + return + } + + setEnabled(checked) + onToggleChange?.(checked) + + toast({ + title: "Success", + description: checked + ? "Version upgrade notifications enabled" + : "Version upgrade notifications disabled", + }) + } catch (error) { + console.error("Error updating upgrade toast setting:", error) + toast({ + title: "Error", + description: "Failed to update upgrade notification setting", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+

+ Version upgrade notifications +

+
+

+ When enabled, users will see a notification when a new version of Sourcebot is available on GitHub. + Otherwise, only the owner will be notified when a new version becomes available. +

+
+
+
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 2ceb5d30e..0c4b8f941 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export const orgMetadataSchema = z.object({ anonymousAccessEnabled: z.boolean().optional(), + upgradeToastEnabled: z.boolean().optional(), }) export const demoSearchScopeSchema = z.object({