Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1810,6 +1810,55 @@ export const setAnonymousAccessStatus = async (domain: string, enabled: boolean)
});
});

export const getUpgradeToastEnabled = async (domain: string): Promise<boolean | ServiceError> => 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<ServiceError | boolean> => 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", {
Expand Down
14 changes: 11 additions & 3 deletions packages/web/src/app/[domain]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -142,7 +149,7 @@ export default async function Layout(props: LayoutProps) {
</div>
)
}

const hasUnlinkedProviders = linkedAccountProviderStates.some(state => state.isLinked === false);
if (hasUnlinkedProviders) {
const cookieStore = await cookies();
Expand Down Expand Up @@ -188,12 +195,13 @@ export default async function Layout(props: LayoutProps) {
<MobileUnsupportedSplashScreen />
)
}

return (
<SyntaxGuideProvider>
{children}
<SyntaxReferenceGuide />
<GitHubStarToast />
<UpgradeToast />
{(upgradeToastEnabled || userRole === OrgRole.OWNER) ? <UpgradeToast /> : null}
</SyntaxGuideProvider>
)
}
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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);
Expand All @@ -39,6 +41,11 @@ export async function OrganizationAccessSettings() {
inviteLinkEnabled={org.inviteLinkEnabled}
inviteLink={inviteLink}
/>

<UpgradeToastToggle
upgradeToastEnabled={upgradeToastEnabled}
/>

</div>
)
}
79 changes: 79 additions & 0 deletions packages/web/src/app/components/upgradeToastToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-4 rounded-lg border border-[var(--border)] bg-[var(--card)]">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-[var(--foreground)] mb-2">
Version upgrade notifications
</h3>
<div className="max-w-2xl">
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
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.
</p>
</div>
</div>
<div className="flex-shrink-0">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isLoading}
/>
</div>
</div>
</div>
)
}
1 change: 1 addition & 0 deletions packages/web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down