-
+
)
}
-export const getSidebarNavItems = async () =>
+export const getSidebarNavGroups = async () =>
withAuth(async ({ role }) => {
let numJoinRequests: number | undefined;
if (role === OrgRole.OWNER) {
@@ -74,49 +59,68 @@ export const getSidebarNavItems = async () =>
throw new ServiceErrorException(connectionStats);
}
- return [
- ...(role === OrgRole.OWNER ? [
- {
- title: "Access",
- href: `/settings/access`,
- }
- ] : []),
- ...(role === OrgRole.OWNER ? [{
- title: "Members",
- isNotificationDotVisible: numJoinRequests !== undefined && numJoinRequests > 0,
- href: `/settings/members`,
- }] : []),
- ...(role === OrgRole.OWNER ? [
- {
- title: "Connections",
- href: `/settings/connections`,
- hrefRegex: `/settings/connections(/[^/]+)?$`,
- isNotificationDotVisible: connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0,
- }
- ] : []),
- ...(env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'false' || role === OrgRole.OWNER ? [
- {
- title: "API Keys",
- href: `/settings/apiKeys`,
- }
- ] : []),
- ...(role === OrgRole.OWNER ? [
- {
- title: "Analytics",
- href: `/settings/analytics`,
- },
- ] : []),
- ...(hasEntitlement("sso") ? [
- {
- title: "Linked Accounts",
- href: `/settings/linked-accounts`,
- }
- ] : []),
- ...(role === OrgRole.OWNER ? [
- {
- title: "License",
- href: `/settings/license`,
- }
- ] : []),
- ]
+ const groups: NavGroup[] = [
+ {
+ label: "Account",
+ items: [
+ {
+ title: "General",
+ href: `/settings/general`,
+ icon: "settings" as const,
+ },
+ ...(env.DISABLE_API_KEY_USAGE_FOR_NON_OWNER_USERS === 'false' || role === OrgRole.OWNER ? [
+ {
+ title: "API Keys",
+ href: `/settings/apiKeys`,
+ icon: "key-round" as const,
+ }
+ ] : []),
+ ...(await hasEntitlement("sso") ? [
+ {
+ title: "Linked Accounts",
+ href: `/settings/linked-accounts`,
+ icon: "link" as const,
+ }
+ ] : []),
+ ],
+ },
+ ];
+
+ if (role === OrgRole.OWNER) {
+ groups.push({
+ label: "Workspace",
+ items: [
+ {
+ title: "Access",
+ href: `/settings/access`,
+ icon: "shield" as const,
+ },
+ {
+ title: "Members",
+ isNotificationDotVisible: numJoinRequests !== undefined && numJoinRequests > 0,
+ href: `/settings/members`,
+ icon: "users" as const,
+ },
+ {
+ title: "Connections",
+ href: `/settings/connections`,
+ hrefRegex: `/settings/connections(/[^/]+)?$`,
+ isNotificationDotVisible: connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0,
+ icon: "plug" as const,
+ },
+ {
+ title: "Analytics",
+ href: `/settings/analytics`,
+ icon: "chart-area" as const,
+ },
+ {
+ title: "License",
+ href: `/settings/license`,
+ icon: "scroll-text" as const,
+ },
+ ],
+ });
+ }
+
+ return groups.filter(g => g.items.length > 0);
});
\ No newline at end of file
diff --git a/packages/web/src/app/(app)/settings/license/activationCodeCard.tsx b/packages/web/src/app/(app)/settings/license/activationCodeCard.tsx
new file mode 100644
index 000000000..7bf9554f0
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/license/activationCodeCard.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { LoadingButton } from "@/components/ui/loading-button";
+import { SettingsCard } from "../components/settingsCard";
+import { activateLicense, createCheckoutSession } from "@/ee/features/lighthouse/actions";
+import { isServiceError } from "@/lib/utils";
+import { useToast } from "@/components/hooks/use-toast";
+import { Separator } from "@/components/ui/separator";
+import { Loader2, ExternalLink } from "lucide-react";
+
+interface ActivationCodeCardProps {
+ isTrialEligible: boolean;
+}
+
+export function ActivationCodeCard({ isTrialEligible }: ActivationCodeCardProps) {
+ const [activationCode, setActivationCode] = useState("");
+ const [isActivating, setIsActivating] = useState(false);
+ const [isCheckoutSessionCreating, setIsCheckoutSessionCreating] = useState(false);
+ const { toast } = useToast();
+
+ const handleActivate = useCallback(() => {
+ if (!activationCode.trim()) {
+ return;
+ }
+
+ setIsActivating(true);
+ activateLicense(activationCode.trim())
+ .then((response) => {
+ if (isServiceError(response)) {
+ toast({
+ description: `Failed to activate license: ${response.message}`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ description: "✅ License activated successfully.",
+ });
+ setActivationCode("");
+ }
+ })
+ .finally(() => {
+ setIsActivating(false);
+ });
+ }, [activationCode, toast]);
+
+ const onCreateCheckoutSession = useCallback(() => {
+ setIsCheckoutSessionCreating(true);
+
+ createCheckoutSession(isTrialEligible)
+ .then((response) => {
+ if (isServiceError(response)) {
+ toast({
+ description: `Failed to start checkout: ${response.message}`,
+ variant: "destructive",
+ });
+ } else {
+ // Stripe Checkout is an external URL; use assign for a
+ // full navigation rather than router.push.
+ window.location.assign(response.url);
+ }
+ })
+ .catch(() => {
+ toast({
+ description: "Failed to start checkout. Please try again.",
+ variant: "destructive",
+ });
+ })
+ .finally(() => {
+ setIsCheckoutSessionCreating(false);
+ });
+ }, [toast, isTrialEligible]);
+
+ return (
+
+
+
Activation code
+
+ Enter your activation code to enable your enterprise license.
+
+
+
+
+ setActivationCode(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleActivate();
+ }
+ }}
+ disabled={isActivating}
+ className="font-mono"
+ />
+
+ Activate
+
+
+
+ Don't have an activation code?
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/app/(app)/settings/license/checkoutSuccessModal.tsx b/packages/web/src/app/(app)/settings/license/checkoutSuccessModal.tsx
new file mode 100644
index 000000000..4ac1bc851
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/license/checkoutSuccessModal.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { useRouter } from "next/navigation";
+import { CheckCircle2 } from "lucide-react";
+import confetti from "canvas-confetti";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { LoadingButton } from "@/components/ui/loading-button";
+import { useToast } from "@/components/hooks/use-toast";
+import { activateLicense } from "@/ee/features/lighthouse/actions";
+import { isServiceError } from "@/lib/utils";
+
+const CONFETTI_COLORS = [
+ "#ff3b3b", // red
+ "#ffb800", // amber
+ "#ffe600", // yellow
+ "#3ecf8e", // green
+ "#3b82f6", // blue
+ "#a855f7", // purple
+ "#ec4899", // pink
+];
+
+const rainConfetti = () => {
+ const duration = 1500;
+ const end = Date.now() + duration;
+ const frame = () => {
+ confetti({
+ particleCount: 10,
+ startVelocity: 20,
+ ticks: 250,
+ spread: 360,
+ gravity: 2.5,
+ colors: CONFETTI_COLORS,
+ origin: { x: Math.random(), y: -0.1 },
+ });
+ if (Date.now() < end) {
+ requestAnimationFrame(frame);
+ }
+ };
+ frame();
+};
+
+interface CheckoutSuccessModalProps {
+ userEmail?: string | null;
+}
+
+export function CheckoutSuccessModal({ userEmail }: CheckoutSuccessModalProps) {
+ const [open, setOpen] = useState(true);
+ const [activationCode, setActivationCode] = useState("");
+ const [isActivating, setIsActivating] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ const dismiss = useCallback(() => {
+ setOpen(false);
+ router.replace("/settings/license");
+ }, [router]);
+
+ const handleOpenChange = useCallback((next: boolean) => {
+ if (!next) {
+ dismiss();
+ }
+ }, [dismiss]);
+
+ const handleActivate = useCallback(() => {
+ const code = activationCode.trim();
+ if (!code) {
+ return;
+ }
+
+ setIsActivating(true);
+ activateLicense(code)
+ .then((response) => {
+ if (isServiceError(response)) {
+ toast({
+ description: `Failed to activate license: ${response.message}`,
+ variant: "destructive",
+ });
+ return;
+ }
+
+ toast({
+ description: "✅ License activated successfully.",
+ });
+ rainConfetti();
+ dismiss();
+ })
+ .finally(() => {
+ setIsActivating(false);
+ });
+ }, [activationCode, toast, dismiss]);
+
+ return (
+
+ );
+}
diff --git a/packages/web/src/app/(app)/settings/license/offlineLicenseCard.tsx b/packages/web/src/app/(app)/settings/license/offlineLicenseCard.tsx
new file mode 100644
index 000000000..81707689e
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/license/offlineLicenseCard.tsx
@@ -0,0 +1,57 @@
+import type { OfflineLicenseMetadata } from "@sourcebot/shared";
+import { Badge } from "@/components/ui/badge";
+import { SettingsCard } from "../components/settingsCard";
+
+interface OfflineLicenseCardProps {
+ license: OfflineLicenseMetadata;
+ isExpired: boolean;
+}
+
+export function OfflineLicenseCard({ license, isExpired }: OfflineLicenseCardProps) {
+ const expiryDate = new Date(license.expiryDate);
+ const truncatedId = license.id.length > 12
+ ? `${license.id.slice(0, 8)}…`
+ : license.id;
+
+ return (
+
+
+
+
+
Enterprise plan
+ {isExpired && (
+
+ Expired
+
+ )}
+
+
+ {truncatedId}
+
+
+
+ {license.seats !== undefined && (
+
+
Billed seats
+
{license.seats}
+
+ )}
+
+
+ {isExpired ? "Expired on" : "Expires on"}
+
+
{formatDate(expiryDate)}
+
+
+
+
+ );
+}
+
+function formatDate(date: Date): string {
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
diff --git a/packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx b/packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx
new file mode 100644
index 000000000..398109622
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/license/onlineLicenseCard.tsx
@@ -0,0 +1,181 @@
+import { License } from "@sourcebot/db";
+import { LicenseStatus, STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS } from "@sourcebot/shared";
+import { formatDistanceToNow } from "date-fns";
+import { AlertTriangle } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { cn, formatCurrency } from "@/lib/utils";
+import { SettingsCard } from "../components/settingsCard";
+import { PlanActionsMenu } from "./planActionsMenu";
+
+interface OnlineLicenseCardProps {
+ license: License;
+}
+
+export function OnlineLicenseCard({ license }: OnlineLicenseCardProps) {
+ const {
+ planName,
+ unitAmount,
+ currency,
+ interval,
+ intervalCount,
+ seats,
+ nextRenewalAt,
+ nextRenewalAmount,
+ cancelAt,
+ trialEnd,
+ } = license;
+
+ // Require the fields needed to render the plan header. nextRenewalAt is
+ // optional here because non-active subscriptions hide the renewal column.
+ if (
+ !planName
+ || unitAmount === null
+ || !currency
+ || !interval
+ || intervalCount === null
+ ) {
+ return null;
+ }
+
+ const monthlyPerSeat = normalizeToMonthly(unitAmount, interval, intervalCount);
+ const statusBadge = getStatusBadge(license.status);
+ const isActivelyBilling =
+ license.status === 'active'
+ || license.status === 'trialing'
+ || license.status === 'past_due';
+
+ return (
+
+
+
+
+
{planName} plan
+ {statusBadge && (
+
+ {statusBadge.label}
+
+ )}
+
+ {monthlyPerSeat !== null ? (
+
+ {formatCurrency(monthlyPerSeat, currency, { minimumFractionDigits: 0 })} per user/mo, billed {formatCadence(interval, intervalCount)}
+
+ ) : (
+
+ {formatCurrency(unitAmount, currency, { minimumFractionDigits: 0 })} per user, billed {formatCadence(interval, intervalCount)}
+
+ )}
+
+
sb_act_••••
+ {license.lastSyncAt && (
+ <>
+
·
+
+ {isLastSyncStale(license.lastSyncAt) && (
+
+ )}
+ Verified {formatDistanceToNow(license.lastSyncAt, { addSuffix: true })}
+
+ >
+ )}
+
+
+
+ {isActivelyBilling && (nextRenewalAt || cancelAt || trialEnd) && (
+
+
+
Billed seats
+
{seats ?? 0}
+
+ {nextRenewalAt ? (
+
+
Next renewal
+
+ {formatCurrency(nextRenewalAmount ?? 0, currency, { minimumFractionDigits: 0 })} on {formatDate(nextRenewalAt)}
+
+
+ ) : cancelAt ? (
+
+
Cancels on
+
{formatDate(cancelAt)}
+
+ ) : trialEnd && (
+
+
Trial ends on
+
{formatDate(trialEnd)}
+
+ )}
+
+ )}
+
+
+
+
+ );
+}
+
+function normalizeToMonthly(unitAmount: number, interval: string, intervalCount: number): number | null {
+ if (interval === 'year') {
+ return unitAmount / (12 * intervalCount);
+ }
+ if (interval === 'month') {
+ return unitAmount / intervalCount;
+ }
+ return null;
+}
+
+
+function formatCadence(interval: string, intervalCount: number): string {
+ if (intervalCount === 1) {
+ if (interval === 'year') {
+ return 'annually';
+ }
+ if (interval === 'month') {
+ return 'monthly';
+ }
+ if (interval === 'week') {
+ return 'weekly';
+ }
+ if (interval === 'day') {
+ return 'daily';
+ }
+ }
+ if (interval === 'month' && intervalCount === 3) {
+ return 'quarterly';
+ }
+ if (interval === 'month' && intervalCount === 6) {
+ return 'semi-annually';
+ }
+ return `every ${intervalCount} ${interval}s`;
+}
+
+const STATUS_BADGES: Record
= {
+ active: { label: 'Current', className: 'border-primary/30 text-primary' },
+ trialing: { label: 'Trial', className: 'border-primary/30 text-primary' },
+ past_due: { label: 'Past due', className: 'border-destructive/30 text-destructive' },
+ unpaid: { label: 'Unpaid', className: 'border-destructive/30 text-destructive' },
+ incomplete: { label: 'Incomplete', className: 'border-destructive/30 text-destructive' },
+ canceled: { label: 'Canceled', className: 'border-muted-foreground/30 text-muted-foreground' },
+ incomplete_expired: { label: 'Expired', className: 'border-muted-foreground/30 text-muted-foreground' },
+ paused: { label: 'Paused', className: 'border-muted-foreground/30 text-muted-foreground' },
+};
+
+function getStatusBadge(status: string | null): { label: string; className: string } | null {
+ if (status && status in STATUS_BADGES) {
+ return STATUS_BADGES[status as LicenseStatus];
+ }
+ return null;
+}
+
+function formatDate(date: Date): string {
+ return new Date(date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
+function isLastSyncStale(lastSyncAt: Date): boolean {
+ return Date.now() - lastSyncAt.getTime() > STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS;
+}
+
diff --git a/packages/web/src/app/(app)/settings/license/page.tsx b/packages/web/src/app/(app)/settings/license/page.tsx
index fa4e6ba9f..2d6e66020 100644
--- a/packages/web/src/app/(app)/settings/license/page.tsx
+++ b/packages/web/src/app/(app)/settings/license/page.tsx
@@ -1,118 +1,98 @@
-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 { isServiceError } from "@/lib/utils";
-import { ServiceErrorException } from "@/lib/serviceError";
import { authenticatedPage } from "@/middleware/authenticatedPage";
import { OrgRole } from "@sourcebot/db";
+import { getOfflineLicenseMetadata } from "@sourcebot/shared";
+import { Button } from "@/components/ui/button";
+import { ExternalLink } from "lucide-react";
+import { redirect } from "next/navigation";
+import { ActivationCodeCard } from "./activationCodeCard";
+import { CheckoutSuccessModal } from "./checkoutSuccessModal";
+import { OnlineLicenseCard } from "./onlineLicenseCard";
+import { OfflineLicenseCard } from "./offlineLicenseCard";
+import { RecentInvoicesCard } from "./recentInvoicesCard";
+import { getAllInvoices } from "@/ee/features/lighthouse/actions";
+import { syncWithLighthouse } from "@/ee/features/lighthouse/servicePing";
+import { isServiceError } from "@/lib/utils";
-export default authenticatedPage(async () => {
- const licenseKey = getLicenseKey();
- const entitlements = getEntitlements();
- const plan = getPlan();
+type LicensePageProps = {
+ searchParams?: Promise>;
+} & Record;
- if (!licenseKey) {
- return (
-
-
-
License
-
View your license details.
-
+export default authenticatedPage
(async ({ prisma, org, user }, props) => {
+ const searchParams = await props.searchParams;
+ if (searchParams?.refresh === 'true' || searchParams?.trial_used === 'true') {
+ // Side-trips to the Stripe portal (add PM, manage sub) include
+ // ?refresh=true so we resync immediately instead of waiting for
+ // the daily ping. Trial checkout returns add ?trial_used=true so
+ // we can flag the org as having used its trial even before the
+ // license row exists (the user still needs to enter the
+ // activation code from email before syncWithLighthouse has
+ // anything to pull).
+ if (searchParams.refresh === 'true') {
+ await syncWithLighthouse(org.id).catch(() => {
+ // ignore failure
+ });
+ }
+ if (searchParams.trial_used === 'true' && org.trialUsedAt === null) {
+ await prisma.org.update({
+ where: { id: org.id, trialUsedAt: null },
+ data: { trialUsedAt: new Date() },
+ }).catch(() => {
+ // No-op: the flag was already set by another path.
+ });
+ }
-
-
-
No License Found
-
- Check out the docs for more information.
-
-
-
- Want to try out Sourcebot's enterprise features? Reach out to us and we'll get back to you within
- a couple hours with a trial license.
-
-
-
-
-
- )
+ // Strip our params but preserve anything else (e.g. `checkout=success`).
+ const preserved = new URLSearchParams(searchParams as Record);
+ preserved.delete('refresh');
+ preserved.delete('trial_used');
+ const suffix = preserved.toString();
+ redirect(suffix ? `/settings/license?${suffix}` : '/settings/license');
}
- const members = await getOrgMembers();
- if (isServiceError(members)) {
- throw new ServiceErrorException(members);
- }
-
- const numMembers = members.length;
- const expiryDate = new Date(licenseKey.expiryDate);
- const isExpired = expiryDate < new Date();
- const seats = licenseKey.seats;
- const isUnlimited = seats === SOURCEBOT_UNLIMITED_SEATS;
-
- return (
-
-
-
-
-
-
License Details
-
-
-
-
License ID
-
{licenseKey.id}
-
+ const offlineLicense = getOfflineLicenseMetadata();
+ const isOfflineLicenseExpired = offlineLicense
+ ? new Date(offlineLicense.expiryDate).getTime() < Date.now()
+ : false;
-
+ const license = offlineLicense
+ ? null
+ : await prisma.license.findUnique({ where: { orgId: org.id } });
-
-
Entitlements
-
{entitlements?.join(", ") || "None"}
-
+ const invoicesResult = license ? await getAllInvoices() : null;
+ const invoices = invoicesResult && !isServiceError(invoicesResult) ? invoicesResult : [];
-
-
Seats
-
- {isUnlimited ? 'Unlimited' : `${numMembers} / ${seats}`}
-
-
+ const isTrialEligible = !offlineLicense && org.trialUsedAt === null;
+ const showCheckoutSuccess = searchParams?.checkout === 'success' && !license;
-
-
Expiry Date
-
- {expiryDate.toLocaleString("en-US", {
- hour: "2-digit",
- minute: "2-digit",
- month: "long",
- day: "numeric",
- year: "numeric",
- timeZoneName: "short"
- })} {isExpired && '(Expired)'}
-
-
-
+ return (
+
+
+ {offlineLicense && (
+
+ )}
+ {license &&
}
+ {license &&
}
+ {!offlineLicense && !license &&
}
+ {showCheckoutSuccess &&
}
- )
-}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });
+ );
+}, {
+ minRole: OrgRole.OWNER,
+ redirectTo: '/settings'
+});
diff --git a/packages/web/src/app/(app)/settings/license/planActionsMenu.tsx b/packages/web/src/app/(app)/settings/license/planActionsMenu.tsx
new file mode 100644
index 000000000..3e1410fa9
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/license/planActionsMenu.tsx
@@ -0,0 +1,156 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { useRouter } from "next/navigation";
+import { MoreVertical, RefreshCw, ExternalLink, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { useToast } from "@/components/hooks/use-toast";
+import { refreshLicense, createPortalSession, deactivateLicense } from "@/ee/features/lighthouse/actions";
+import { isServiceError, cn } from "@/lib/utils";
+
+export function PlanActionsMenu() {
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [isOpeningPortal, setIsOpeningPortal] = useState(false);
+ const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const handleRemove = useCallback(() => {
+ deactivateLicense()
+ .then((response) => {
+ if (isServiceError(response)) {
+ toast({
+ description: `Failed to remove activation code: ${response.message}`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ description: "Activation code removed successfully.",
+ });
+ router.refresh();
+ }
+ });
+ }, [router, toast]);
+
+ const handleRefresh = useCallback(() => {
+ setIsRefreshing(true);
+ refreshLicense()
+ .then((response) => {
+ if (isServiceError(response)) {
+ toast({
+ description: `Failed to refresh license: ${response.message}`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ description: "License refreshed successfully.",
+ });
+ router.refresh();
+ }
+ })
+ .finally(() => {
+ setIsRefreshing(false);
+ });
+ }, [router, toast]);
+
+ const handleManage = useCallback(() => {
+ setIsOpeningPortal(true);
+ createPortalSession()
+ .then((response) => {
+ if (isServiceError(response)) {
+ toast({
+ description: `Failed to open subscription portal: ${response.message}`,
+ variant: "destructive",
+ });
+ setIsOpeningPortal(false);
+ } else {
+ router.push(response.url);
+ }
+ });
+ }, [router, toast]);
+
+ const isBusy = isRefreshing || isOpeningPortal;
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Manage subscription
+
+ {
+ e.preventDefault();
+ handleRefresh();
+ }}
+ disabled={isRefreshing}
+ >
+
+ Refresh subscription
+
+
+ setIsRemoveDialogOpen(true)}
+ >
+
+ Remove activation code
+
+
+
+
+
+
+
+ Remove activation code
+
+ Are you sure you want to remove this activation code? Your deployment will no longer have a registered license.
+
+
+
+ Cancel
+
+ Remove
+
+
+
+
+ >
+ );
+}
diff --git a/packages/web/src/app/(app)/settings/license/recentInvoicesCard.tsx b/packages/web/src/app/(app)/settings/license/recentInvoicesCard.tsx
new file mode 100644
index 000000000..1ea6d5db0
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/license/recentInvoicesCard.tsx
@@ -0,0 +1,84 @@
+import Link from "next/link";
+import { ExternalLink } from "lucide-react";
+import { Invoice } from "@/ee/features/lighthouse/types";
+import { SettingsCard, SettingsCardGroup } from "../components/settingsCard";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { formatCurrency } from "@/lib/utils";
+
+interface RecentInvoicesCardProps {
+ invoices: Invoice[];
+}
+
+export function RecentInvoicesCard({ invoices }: RecentInvoicesCardProps) {
+ if (invoices.length === 0) {
+ return null;
+ }
+
+ return (
+
+
Recent invoices
+
+ {invoices.map((invoice) => (
+
+ ))}
+
+
+ );
+}
+
+function InvoiceRow({ invoice }: { invoice: Invoice }) {
+ const statusBadge = getStatusBadge(invoice.status);
+ return (
+
+
+
{formatDate(invoice.createdAt)}
+
+
+ {formatCurrency(invoice.amount, invoice.currency)}
+
+ {statusBadge && (
+
+ {statusBadge.label}
+
+ )}
+
+ {invoice.hostedInvoiceUrl && (
+
+ )}
+
+
+ );
+}
+
+// Maps Stripe invoice statuses to display badges. See:
+// https://docs.stripe.com/invoicing/overview#invoice-statuses
+const STATUS_BADGES: Record
= {
+ draft: { label: 'Draft', className: 'border-muted-foreground/30 text-muted-foreground' },
+ open: { label: 'Open', className: 'border-primary/30 text-primary' },
+ paid: { label: 'Paid', className: 'border-primary/30 text-primary' },
+ uncollectible: { label: 'Uncollectible', className: 'border-destructive/30 text-destructive' },
+ void: { label: 'Void', className: 'border-muted-foreground/30 text-muted-foreground' },
+};
+
+function getStatusBadge(status: string): { label: string; className: string } | null {
+ return STATUS_BADGES[status] ?? null;
+}
+
+function formatDate(isoDate: string): string {
+ return new Date(isoDate).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
+
diff --git a/packages/web/src/app/(app)/settings/linked-accounts/page.tsx b/packages/web/src/app/(app)/settings/linked-accounts/page.tsx
index 17d49d1a5..d0411174e 100644
--- a/packages/web/src/app/(app)/settings/linked-accounts/page.tsx
+++ b/packages/web/src/app/(app)/settings/linked-accounts/page.tsx
@@ -43,7 +43,7 @@ export default async function LinkedAccountsPage() {
) : (
-
+
{linkedAccounts
.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
.map((account) => (
diff --git a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx
index 878d50892..b2d92d52d 100644
--- a/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx
+++ b/packages/web/src/app/(app)/settings/members/components/inviteMemberCard.tsx
@@ -8,10 +8,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useCallback, useState } from "react";
import { z } from "zod";
-import { PlusCircleIcon, Loader2, AlertCircle } from "lucide-react";
+import { PlusCircleIcon, Loader2, AlertTriangle } 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 { createInvites } from "@/features/userManagement/actions";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
@@ -28,10 +28,10 @@ export const inviteMemberFormSchema = z.object({
interface InviteMemberCardProps {
currentUserRole: OrgRole;
- seatsAvailable?: boolean;
+ seatsAvailable: boolean;
}
-export const InviteMemberCard = ({ currentUserRole, seatsAvailable = true }: InviteMemberCardProps) => {
+export const InviteMemberCard = ({ currentUserRole, seatsAvailable }: InviteMemberCardProps) => {
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
@@ -82,20 +82,20 @@ export const InviteMemberCard = ({ currentUserRole, seatsAvailable = true }: Inv
return (
<>
-
+
Invite Member
Invite new members to your organization.
{!seatsAvailable && (
-
-
+
+
-
+
Maximum seats reached
-
+
You've reached the maximum number of seats for your license. Upgrade your plan to invite additional members.
diff --git a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx
index 42ab41135..b5ebb4883 100644
--- a/packages/web/src/app/(app)/settings/members/components/invitesList.tsx
+++ b/packages/web/src/app/(app)/settings/members/components/invitesList.tsx
@@ -11,7 +11,7 @@ import { createPathWithQueryParams, isServiceError } from "@/lib/utils";
import { UserAvatar } from "@/components/userAvatar";
import { Copy, MoreVertical, Search } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
-import { cancelInvite } from "@/actions";
+import { cancelInvite } from "@/features/userManagement/actions";
import { useRouter } from "next/navigation";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface Invite {
diff --git a/packages/web/src/app/(app)/settings/members/components/membersList.tsx b/packages/web/src/app/(app)/settings/members/components/membersList.tsx
index 63810ce73..b56958cdb 100644
--- a/packages/web/src/app/(app)/settings/members/components/membersList.tsx
+++ b/packages/web/src/app/(app)/settings/members/components/membersList.tsx
@@ -145,7 +145,7 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName,
description: `✅ You have left the organization.`
})
captureEvent('wa_members_list_leave_org_success', {})
- router.push("/");
+ router.refresh();
}
});
}, [toast, router, captureEvent]);
diff --git a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx
index 5856a0cc6..2412f0eb7 100644
--- a/packages/web/src/app/(app)/settings/members/components/requestsList.tsx
+++ b/packages/web/src/app/(app)/settings/members/components/requestsList.tsx
@@ -10,7 +10,7 @@ import { isServiceError } from "@/lib/utils";
import { UserAvatar } from "@/components/userAvatar";
import { CheckCircle, Search, XCircle } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
-import { approveAccountRequest, rejectAccountRequest } from "@/actions";
+import { rejectAccountRequest, approveAccountRequest } from "@/features/userManagement/actions";
import { useRouter } from "next/navigation";
import useCaptureEvent from "@/hooks/useCaptureEvent";
diff --git a/packages/web/src/app/(app)/settings/members/page.tsx b/packages/web/src/app/(app)/settings/members/page.tsx
index b8e0a834e..d991b5df6 100644
--- a/packages/web/src/app/(app)/settings/members/page.tsx
+++ b/packages/web/src/app/(app)/settings/members/page.tsx
@@ -1,18 +1,19 @@
import { MembersList } from "./components/membersList";
-import { getOrgMembers } from "@/actions";
+import { getOrgMembers, getOrgInvites, getOrgAccountRequests } from "@/features/userManagement/actions";
import { isServiceError } from "@/lib/utils";
import { InviteMemberCard } from "./components/inviteMemberCard";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabSwitcher } from "@/components/ui/tab-switcher";
import { InvitesList } from "./components/invitesList";
-import { getOrgInvites, getMe, getOrgAccountRequests } from "@/actions";
import { ServiceErrorException } from "@/lib/serviceError";
-import { getSeats, hasEntitlement, SOURCEBOT_UNLIMITED_SEATS } from "@sourcebot/shared";
+import { hasEntitlement } from "@/lib/entitlements";
import { RequestsList } from "./components/requestsList";
import { OrgRole } from "@sourcebot/db";
import { NotificationDot } from "../../components/notificationDot";
import { Badge } from "@/components/ui/badge";
import { authenticatedPage } from "@/middleware/authenticatedPage";
+import { orgHasAvailability } from "@/lib/authUtils";
+import { getSeatCap } from "@sourcebot/shared";
type MembersSettingsPageProps = {
searchParams: Promise<{
@@ -20,18 +21,13 @@ type MembersSettingsPageProps = {
}>
}
-export default authenticatedPage
(async ({ org, role }, props) => {
+export default authenticatedPage(async ({ org, role, user }, props) => {
const searchParams = await props.searchParams;
const {
tab
} = searchParams;
- const me = await getMe();
- if (isServiceError(me)) {
- throw new ServiceErrorException(me);
- }
-
const members = await getOrgMembers();
if (isServiceError(members)) {
throw new ServiceErrorException(members);
@@ -49,9 +45,8 @@ export default authenticatedPage(async ({ org, role },
const currentTab = tab || "members";
- const seats = getSeats();
- const usedSeats = members.length
- const seatsAvailable = seats === SOURCEBOT_UNLIMITED_SEATS || usedSeats < seats;
+ const hasAvailability = await orgHasAvailability(org.id);
+ const seatCap = getSeatCap();
return (
@@ -60,12 +55,12 @@ export default authenticatedPage
(async ({ org, role },
Members
Invite and manage members of your organization.
- {seats && seats !== SOURCEBOT_UNLIMITED_SEATS && (
+ {seatCap && (
- {usedSeats}
+ {members.length}
of
- {seats}
+ {seatCap}
seats used
@@ -74,7 +69,7 @@ export default authenticatedPage(async ({ org, role },
@@ -131,10 +126,10 @@ export default authenticatedPage(async ({ org, role },
@@ -158,4 +153,7 @@ export default authenticatedPage(async ({ org, role },
)
-}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });
+}, {
+ minRole: OrgRole.OWNER,
+ redirectTo: '/settings'
+});
diff --git a/packages/web/src/app/(app)/settings/page.tsx b/packages/web/src/app/(app)/settings/page.tsx
index e059b95c5..04ac40ff2 100644
--- a/packages/web/src/app/(app)/settings/page.tsx
+++ b/packages/web/src/app/(app)/settings/page.tsx
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
-import { getSidebarNavItems } from "./layout";
+import { getSidebarNavGroups } from "./layout";
import { isServiceError } from "@/lib/utils";
import { ServiceErrorException } from "@/lib/serviceError";
import { auth } from "@/auth";
@@ -10,9 +10,9 @@ export default async function SettingsPage() {
return redirect(`/`);
}
- const items = await getSidebarNavItems();
- if (isServiceError(items)) {
- throw new ServiceErrorException(items);
+ const groups = await getSidebarNavGroups();
+ if (isServiceError(groups)) {
+ throw new ServiceErrorException(groups);
}
- return redirect(items[0].href);
+ return redirect(groups[0].items[0].href);
}
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts
index 93dd269ee..22c689278 100644
--- a/packages/web/src/app/api/(client)/client.ts
+++ b/packages/web/src/app/api/(client)/client.ts
@@ -2,6 +2,7 @@
import { ServiceError } from "@/lib/serviceError";
import { GetVersionResponse, ListReposQueryParams, ListReposResponse } from "@/lib/types";
+import type { ListChatsQueryParams, ListChatsResponse } from "../(server)/chats/types";
import { isServiceError } from "@/lib/utils";
import {
SearchRequest,
@@ -195,4 +196,22 @@ export const searchChatShareableMembers = async (
}).then(response => response.json());
return result as SearchChatShareableMembersResponse | ServiceError;
+}
+
+export const listChats = async (queryParams: ListChatsQueryParams): Promise
=> {
+ const url = new URL("/api/chats", window.location.origin);
+ for (const [key, value] of Object.entries(queryParams)) {
+ if (value !== undefined) {
+ url.searchParams.set(key, String(value));
+ }
+ }
+
+ const result = await fetch(url, {
+ method: "GET",
+ headers: {
+ "X-Sourcebot-Client-Source": "sourcebot-web-client",
+ },
+ }).then(response => response.json());
+
+ return result as ListChatsResponse | ServiceError;
}
\ No newline at end of file
diff --git a/packages/web/src/app/api/(server)/chats/route.ts b/packages/web/src/app/api/(server)/chats/route.ts
new file mode 100644
index 000000000..16db1352c
--- /dev/null
+++ b/packages/web/src/app/api/(server)/chats/route.ts
@@ -0,0 +1,73 @@
+import { apiHandler } from "@/lib/apiHandler";
+import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError";
+import { listChatsQueryParamsSchema, ListChatsResponse } from "./types";
+import { isServiceError } from "@/lib/utils";
+import { withAuth } from "@/middleware/withAuth";
+import { NextRequest } from "next/server";
+
+export const GET = apiHandler(async (request: NextRequest) => {
+ const rawParams = Object.fromEntries(
+ Object.keys(listChatsQueryParamsSchema.shape).map(key => [
+ key,
+ request.nextUrl.searchParams.get(key) ?? undefined
+ ])
+ );
+ const parsed = listChatsQueryParamsSchema.safeParse(rawParams);
+
+ if (!parsed.success) {
+ return serviceErrorResponse(
+ queryParamsSchemaValidationError(parsed.error)
+ );
+ }
+
+ const { cursor, limit, query, sortBy, sortOrder } = parsed.data;
+
+ const result = await withAuth(async ({ org, user, prisma }): Promise => {
+ const chats = await prisma.chat.findMany({
+ where: {
+ orgId: org.id,
+ createdById: user.id,
+ ...(query ? {
+ name: {
+ contains: query,
+ mode: "insensitive" as const,
+ },
+ } : {}),
+ },
+ orderBy: [
+ { [sortBy]: sortOrder },
+ { id: "asc" },
+ ],
+ take: limit + 1,
+ ...(cursor ? {
+ cursor: { id: cursor },
+ skip: 1,
+ } : {}),
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ });
+
+ const hasMore = chats.length > limit;
+ const items = hasMore ? chats.slice(0, limit) : chats;
+ const nextCursor = hasMore ? items[items.length - 1].id : null;
+
+ return {
+ chats: items.map(chat => ({
+ ...chat,
+ createdAt: chat.createdAt.toISOString(),
+ updatedAt: chat.updatedAt.toISOString(),
+ })),
+ nextCursor,
+ };
+ });
+
+ if (isServiceError(result)) {
+ return serviceErrorResponse(result);
+ }
+
+ return Response.json(result);
+});
diff --git a/packages/web/src/app/api/(server)/chats/types.ts b/packages/web/src/app/api/(server)/chats/types.ts
new file mode 100644
index 000000000..639541a6c
--- /dev/null
+++ b/packages/web/src/app/api/(server)/chats/types.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+export const listChatsQueryParamsSchema = z.object({
+ cursor: z.string().optional(),
+ limit: z.coerce.number().int().positive().max(100).default(20),
+ query: z.string().optional(),
+ sortBy: z.enum(["name", "updatedAt"]).default("updatedAt"),
+ sortOrder: z.enum(["asc", "desc"]).default("desc"),
+});
+
+export const listChatsResponseSchema = z.object({
+ chats: z.array(z.object({
+ id: z.string(),
+ name: z.string().nullable(),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+ })),
+ nextCursor: z.string().nullable(),
+});
+
+
+
+export type ListChatsQueryParams = z.input;
+export type ListChatsResponse = z.infer;
diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts
index 8f9392d1b..179d632e1 100644
--- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts
+++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-authorization-server/route.ts
@@ -1,12 +1,13 @@
import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
-import { env, hasEntitlement } from '@sourcebot/shared';
+import { env } from '@sourcebot/shared';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
+import { hasEntitlement } from '@/lib/entitlements';
// RFC 8414: OAuth 2.0 Authorization Server Metadata
// @see: https://datatracker.ietf.org/doc/html/rfc8414
// eslint-disable-next-line authz/require-auth-wrapper -- RFC 8414 public metadata endpoint
export const GET = oauthApiHandler(async () => {
- if (!hasEntitlement('oauth')) {
+ if (!await hasEntitlement('oauth')) {
return Response.json(
{ error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 404 }
diff --git a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts
index 2cffed45a..8afdf5031 100644
--- a/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts
+++ b/packages/web/src/app/api/(server)/ee/.well-known/oauth-protected-resource/[...path]/route.ts
@@ -1,7 +1,8 @@
-import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
-import { env, hasEntitlement } from '@sourcebot/shared';
+import { env } from '@sourcebot/shared';
+import { hasEntitlement } from '@/lib/entitlements';
import { NextRequest } from 'next/server';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
+import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
// RFC 9728: OAuth 2.0 Protected Resource Metadata (path-specific form)
// For a resource at /api/mcp, the well-known URI is /.well-known/oauth-protected-resource/api/mcp.
@@ -12,7 +13,7 @@ const PROTECTED_RESOURCES = new Set([
// eslint-disable-next-line authz/require-auth-wrapper -- RFC 9728 public metadata endpoint
export const GET = oauthApiHandler(async (_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) => {
- if (!hasEntitlement('oauth')) {
+ if (!await hasEntitlement('oauth')) {
return Response.json(
{ error: 'not_found', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 404 }
diff --git a/packages/web/src/app/api/(server)/ee/audit/route.ts b/packages/web/src/app/api/(server)/ee/audit/route.ts
index b17d552c6..3de61a645 100644
--- a/packages/web/src/app/api/(server)/ee/audit/route.ts
+++ b/packages/web/src/app/api/(server)/ee/audit/route.ts
@@ -6,7 +6,7 @@ import { ErrorCode } from "@/lib/errorCodes";
import { buildLinkHeader } from "@/lib/pagination";
import { serviceErrorResponse, queryParamsSchemaValidationError } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
-import { getEntitlements } from "@sourcebot/shared";
+import { getEntitlements } from "@/lib/entitlements";
import { StatusCodes } from "http-status-codes";
import { NextRequest } from "next/server";
import { z } from "zod";
@@ -25,7 +25,7 @@ const auditQueryParamsSchema = auditQueryParamsBaseSchema.refine(
// eslint-disable-next-line authz/require-auth-wrapper -- delegates to fetchAuditRecords() which calls withAuth + withMinimumOrgRole(OWNER)
export const GET = apiHandler(async (request: NextRequest) => {
- const entitlements = getEntitlements();
+ const entitlements = await getEntitlements();
if (!entitlements.includes('audit')) {
return serviceErrorResponse({
statusCode: StatusCodes.FORBIDDEN,
diff --git a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts
index c4cc7f245..22847e816 100644
--- a/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts
+++ b/packages/web/src/app/api/(server)/ee/chat/[chatId]/searchMembers/route.ts
@@ -1,10 +1,10 @@
import { apiHandler } from "@/lib/apiHandler";
-import { SOURCEBOT_GUEST_USER_ID } from "@/lib/constants";
import { ErrorCode } from "@/lib/errorCodes";
import { notFound, queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { withAuth } from "@/middleware/withAuth";
-import { env, hasEntitlement } from "@sourcebot/shared";
+import { env } from "@sourcebot/shared";
+import { hasEntitlement } from "@/lib/entitlements";
import { StatusCodes } from "http-status-codes";
import { NextRequest } from "next/server";
import { z } from "zod";
@@ -41,7 +41,7 @@ export const GET = apiHandler(async (
})
}
- if (!hasEntitlement('chat-sharing')) {
+ if (!await hasEntitlement('chat-sharing')) {
return serviceErrorResponse({
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.UNEXPECTED_ERROR,
@@ -94,8 +94,6 @@ export const GET = apiHandler(async (
const excludeUserIds = new Set([
// Exclude the owner
user.id,
- // ... and the guest user
- SOURCEBOT_GUEST_USER_ID,
// ... as well as any existing
...sharedWithUsers.map((s) => s.userId),
]);
diff --git a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts
index 65d96def8..cd964bc26 100644
--- a/packages/web/src/app/api/(server)/ee/oauth/register/route.ts
+++ b/packages/web/src/app/api/(server)/ee/oauth/register/route.ts
@@ -1,7 +1,7 @@
import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
import { requestBodySchemaValidationError, serviceErrorResponse } from '@/lib/serviceError';
import { __unsafePrisma } from '@/prisma';
-import { hasEntitlement } from '@sourcebot/shared';
+import { hasEntitlement } from '@/lib/entitlements';
import { NextRequest } from 'next/server';
import { z } from 'zod';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE, UNPERMITTED_SCHEMES } from '@/ee/features/oauth/constants';
@@ -16,7 +16,7 @@ const registerRequestSchema = z.object({
// eslint-disable-next-line authz/require-auth-wrapper -- RFC 7591 dynamic client registration, intentionally unauthenticated
export const POST = oauthApiHandler(async (request: NextRequest) => {
- if (!hasEntitlement('oauth')) {
+ if (!await hasEntitlement('oauth')) {
return Response.json(
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 403 }
diff --git a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts
index c406eefa7..cad1643cb 100644
--- a/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts
+++ b/packages/web/src/app/api/(server)/ee/oauth/revoke/route.ts
@@ -1,6 +1,6 @@
import { revokeToken } from '@/ee/features/oauth/server';
+import { hasEntitlement } from '@/lib/entitlements';
import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
-import { hasEntitlement } from '@sourcebot/shared';
import { NextRequest } from 'next/server';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
@@ -9,7 +9,7 @@ import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants
// @see: https://datatracker.ietf.org/doc/html/rfc7009
// eslint-disable-next-line authz/require-auth-wrapper -- RFC 7009 token revocation, no user session required
export const POST = oauthApiHandler(async (request: NextRequest) => {
- if (!hasEntitlement('oauth')) {
+ if (!await hasEntitlement('oauth')) {
return Response.json(
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 403 }
diff --git a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts
index f5ee3d8cd..3b9843459 100644
--- a/packages/web/src/app/api/(server)/ee/oauth/token/route.ts
+++ b/packages/web/src/app/api/(server)/ee/oauth/token/route.ts
@@ -1,15 +1,16 @@
import { verifyAndExchangeCode, verifyAndRotateRefreshToken } from '@/ee/features/oauth/server';
import { oauthApiHandler } from '@/ee/features/oauth/apiHandler';
-import { env, hasEntitlement } from '@sourcebot/shared';
+import { env } from '@sourcebot/shared';
import { NextRequest } from 'next/server';
import { OAUTH_NOT_SUPPORTED_ERROR_MESSAGE } from '@/ee/features/oauth/constants';
+import { hasEntitlement } from '@/lib/entitlements';
// OAuth 2.0 Token Endpoint
// Supports grant_type=authorization_code with PKCE (RFC 7636).
// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-3.2
// eslint-disable-next-line authz/require-auth-wrapper -- OAuth token endpoint, authenticated via PKCE code / refresh token, not user session
export const POST = oauthApiHandler(async (request: NextRequest) => {
- if (!hasEntitlement('oauth')) {
+ if (!await hasEntitlement('oauth')) {
return Response.json(
{ error: 'access_denied', error_description: OAUTH_NOT_SUPPORTED_ERROR_MESSAGE },
{ status: 403 }
diff --git a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts
index be23b2e92..f9b32cdee 100644
--- a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts
+++ b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/api.ts
@@ -2,7 +2,8 @@
import { ServiceError } from "@/lib/serviceError";
import { withAuth } from "@/middleware/withAuth";
-import { env, getEntitlements } from "@sourcebot/shared";
+import { env } from "@sourcebot/shared";
+import { getEntitlements } from "@/lib/entitlements";
import { AccountPermissionSyncJobStatus } from "@sourcebot/db";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
@@ -18,7 +19,7 @@ export interface PermissionSyncStatusResponse {
*/
export const getPermissionSyncStatus = async (): Promise => sew(async () =>
withAuth(async ({ prisma, user }) => {
- const entitlements = getEntitlements();
+ const entitlements = await getEntitlements();
if (!entitlements.includes('permission-syncing')) {
return {
statusCode: StatusCodes.FORBIDDEN,
diff --git a/packages/web/src/app/api/(server)/ee/user/route.ts b/packages/web/src/app/api/(server)/ee/user/route.ts
index ea7789663..a91eb17e9 100644
--- a/packages/web/src/app/api/(server)/ee/user/route.ts
+++ b/packages/web/src/app/api/(server)/ee/user/route.ts
@@ -1,6 +1,6 @@
'use server';
-import { getAuditService } from "@/ee/features/audit/factory";
+import { createAudit } from "@/ee/features/audit/audit";
import { apiHandler } from "@/lib/apiHandler";
import { ErrorCode } from "@/lib/errorCodes";
import { serviceErrorResponse, missingQueryParam, notFound } from "@/lib/serviceError";
@@ -8,15 +8,15 @@ import { isServiceError } from "@/lib/utils";
import { withAuth } from "@/middleware/withAuth";
import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
import { OrgRole } from "@sourcebot/db";
-import { createLogger, hasEntitlement } from "@sourcebot/shared";
+import { createLogger } from "@sourcebot/shared";
+import { hasEntitlement } from "@/lib/entitlements";
import { StatusCodes } from "http-status-codes";
import { NextRequest } from "next/server";
const logger = createLogger('ee-user-api');
-const auditService = getAuditService();
export const GET = apiHandler(async (request: NextRequest) => {
- if (!hasEntitlement('org-management')) {
+ if (!await hasEntitlement('org-management')) {
return serviceErrorResponse({
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
@@ -50,7 +50,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
return notFound('User not found');
}
- await auditService.createAudit({
+ await createAudit({
action: "user.read",
actor: {
id: user.id,
@@ -112,7 +112,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
return notFound('User not found');
}
- await auditService.createAudit({
+ await createAudit({
action: "user.delete",
actor: {
id: currentUser.id,
diff --git a/packages/web/src/app/api/(server)/ee/users/route.ts b/packages/web/src/app/api/(server)/ee/users/route.ts
index eb52232f3..58ed5738f 100644
--- a/packages/web/src/app/api/(server)/ee/users/route.ts
+++ b/packages/web/src/app/api/(server)/ee/users/route.ts
@@ -1,21 +1,21 @@
'use server';
-import { getAuditService } from "@/ee/features/audit/factory";
+import { createAudit } from "@/ee/features/audit/audit";
import { apiHandler } from "@/lib/apiHandler";
import { serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { withAuth } from "@/middleware/withAuth";
import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
import { OrgRole } from "@sourcebot/db";
-import { createLogger, hasEntitlement } from "@sourcebot/shared";
+import { createLogger } from "@sourcebot/shared";
+import { hasEntitlement } from "@/lib/entitlements";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
const logger = createLogger('ee-users-api');
-const auditService = getAuditService();
export const GET = apiHandler(async () => {
- if (!hasEntitlement('org-management')) {
+ if (!await hasEntitlement('org-management')) {
return serviceErrorResponse({
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
@@ -62,7 +62,7 @@ export const GET = apiHandler(async () => {
})
);
- await auditService.createAudit({
+ await createAudit({
action: "user.list",
actor: {
id: user.id,
diff --git a/packages/web/src/app/api/(server)/mcp/route.ts b/packages/web/src/app/api/(server)/mcp/route.ts
index 11a6adeb5..9218ccd0a 100644
--- a/packages/web/src/app/api/(server)/mcp/route.ts
+++ b/packages/web/src/app/api/(server)/mcp/route.ts
@@ -9,16 +9,17 @@ import { StatusCodes } from 'http-status-codes';
import { NextRequest } from 'next/server';
import { sew } from "@/middleware/sew";
import { apiHandler } from '@/lib/apiHandler';
-import { env, hasEntitlement } from '@sourcebot/shared';
+import { env } from '@sourcebot/shared';
+import { hasEntitlement } from '@/lib/entitlements';
// On 401, tell MCP clients where to find the OAuth protected resource metadata (RFC 9728)
// so they can discover the authorization server and initiate the authorization code flow.
// Only advertised when the oauth entitlement is active.
// @see: https://modelcontextprotocol.io/specification/2025-03-26/basic/authentication
// @see: https://datatracker.ietf.org/doc/html/rfc9728
-function mcpErrorResponse(error: ServiceError): Response {
+async function mcpErrorResponse(error: ServiceError): Promise {
const response = serviceErrorResponse(error);
- if (error.statusCode === StatusCodes.UNAUTHORIZED && hasEntitlement('oauth')) {
+ if (error.statusCode === StatusCodes.UNAUTHORIZED && await hasEntitlement('oauth')) {
const issuer = env.AUTH_URL.replace(/\/$/, '');
response.headers.set(
'WWW-Authenticate',
@@ -87,7 +88,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (isServiceError(response)) {
- return mcpErrorResponse(response);
+ return await mcpErrorResponse(response);
}
return response;
@@ -123,7 +124,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
);
if (isServiceError(result)) {
- return mcpErrorResponse(result);
+ return await mcpErrorResponse(result);
}
return result;
diff --git a/packages/web/src/app/api/(server)/repos/listReposApi.ts b/packages/web/src/app/api/(server)/repos/listReposApi.ts
index 6844feb7e..dbfaf2955 100644
--- a/packages/web/src/app/api/(server)/repos/listReposApi.ts
+++ b/packages/web/src/app/api/(server)/repos/listReposApi.ts
@@ -1,5 +1,5 @@
import { sew } from "@/middleware/sew";
-import { getAuditService } from "@/ee/features/audit/factory";
+import { createAudit } from "@/ee/features/audit/audit";
import { ListReposQueryParams, RepositoryQuery } from "@/lib/types";
import { withOptionalAuth } from "@/middleware/withAuth";
import { getBrowsePath } from "@/app/(app)/browse/hooks/utils";
@@ -10,7 +10,7 @@ export const listRepos = async ({ query, page, perPage, sort, direction, source
withOptionalAuth(async ({ org, prisma, user }) => {
if (user) {
const resolvedSource = source ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
- getAuditService().createAudit({
+ await createAudit({
action: 'user.listed_repos',
actor: { id: user.id, type: 'user' },
target: { id: org.id.toString(), type: 'org' },
diff --git a/packages/web/src/app/components/anonymousAccessToggle.tsx b/packages/web/src/app/components/anonymousAccessToggle.tsx
index 1079f2ed5..233faca5f 100644
--- a/packages/web/src/app/components/anonymousAccessToggle.tsx
+++ b/packages/web/src/app/components/anonymousAccessToggle.tsx
@@ -7,13 +7,13 @@ import { isServiceError } from "@/lib/utils"
import { useToast } from "@/components/hooks/use-toast"
interface AnonymousAccessToggleProps {
- hasAnonymousAccessEntitlement: boolean;
+ anonymousAccessAvailable: boolean;
anonymousAccessEnabled: boolean
forceEnableAnonymousAccess: boolean
onToggleChange?: (checked: boolean) => void
}
-export function AnonymousAccessToggle({ hasAnonymousAccessEntitlement, anonymousAccessEnabled, forceEnableAnonymousAccess, onToggleChange }: AnonymousAccessToggleProps) {
+export function AnonymousAccessToggle({ anonymousAccessAvailable, anonymousAccessEnabled, forceEnableAnonymousAccess, onToggleChange }: AnonymousAccessToggleProps) {
const [enabled, setEnabled] = useState(anonymousAccessEnabled)
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
@@ -45,12 +45,12 @@ export function AnonymousAccessToggle({ hasAnonymousAccessEntitlement, anonymous
setIsLoading(false)
}
}
- const isDisabled = isLoading || !hasAnonymousAccessEntitlement || forceEnableAnonymousAccess;
- const showPlanMessage = !hasAnonymousAccessEntitlement;
+ const isDisabled = isLoading || !anonymousAccessAvailable || forceEnableAnonymousAccess;
+ const showPlanMessage = !anonymousAccessAvailable;
const showForceEnableMessage = !showPlanMessage && forceEnableAnonymousAccess;
return (
-
+
@@ -108,7 +108,7 @@ export function AnonymousAccessToggle({ hasAnonymousAccessEntitlement, anonymous
/>
- The forceEnableAnonymousAccess is set, so this cannot be changed from the UI.
+ FORCE_ENABLE_ANONYMOUS_ACCESS is set, so this cannot be changed from the UI.
diff --git a/packages/web/src/app/components/organizationAccessSettings.tsx b/packages/web/src/app/components/organizationAccessSettings.tsx
index d6846e933..4b3e09605 100644
--- a/packages/web/src/app/components/organizationAccessSettings.tsx
+++ b/packages/web/src/app/components/organizationAccessSettings.tsx
@@ -1,10 +1,10 @@
import { createInviteLink } from "@/lib/utils"
import { AnonymousAccessToggle } from "./anonymousAccessToggle"
import { OrganizationAccessSettingsWrapper } from "./organizationAccessSettingsWrapper"
-import { getOrgMetadata } from "@/lib/utils"
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"
import { __unsafePrisma } from "@/prisma"
-import { hasEntitlement, env } from "@sourcebot/shared"
+import { env } from "@sourcebot/shared"
+import { isAnonymousAccessAvailable, isAnonymousAccessEnabled } from "@/lib/entitlements"
export async function OrganizationAccessSettings() {
const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
@@ -12,13 +12,11 @@ export async function OrganizationAccessSettings() {
return
Error loading organization
}
- const metadata = getOrgMetadata(org);
- const anonymousAccessEnabled = metadata?.anonymousAccessEnabled ?? false;
-
const baseUrl = env.AUTH_URL;
const inviteLink = createInviteLink(baseUrl, org.inviteLinkId)
- const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access");
+ const anonymousAccessEnabled = await isAnonymousAccessEnabled();
+ const anonymousAccessAvailable = await isAnonymousAccessAvailable();
const forceEnableAnonymousAccess = env.FORCE_ENABLE_ANONYMOUS_ACCESS === 'true';
const memberApprovalEnvVarSet = env.REQUIRE_APPROVAL_NEW_MEMBERS !== undefined;
@@ -26,7 +24,7 @@ export async function OrganizationAccessSettings() {
return (
diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css
index ca00492d2..93da02b84 100644
--- a/packages/web/src/app/globals.css
+++ b/packages/web/src/app/globals.css
@@ -53,6 +53,7 @@
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--link: hsl(217, 91%, 60%);
+ --shell: hsl(0 0% 98%);
--editor-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--editor-font-size: 13px;
@@ -113,10 +114,10 @@
}
.dark {
- --background: hsl(222.2 84% 4.9%);
+ --background: hsl(0, 0%, 6%);
--background-secondary: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
- --card: hsl(222.2 84% 4.9%);
+ --card: hsl(221, 89%, 4%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%);
@@ -140,7 +141,7 @@
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--highlight: hsl(217 91% 60%);
- --sidebar-background: hsl(240 5.9% 10%);
+ --sidebar-background: hsl(0, 0%, 3%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
@@ -149,6 +150,7 @@
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--link: hsl(217, 91%, 60%);
+ --shell: hsl(0, 0%, 3%);
--editor-background: var(--background);
--editor-foreground: #abb2bf;
diff --git a/packages/web/src/app/invite/actions.ts b/packages/web/src/app/invite/actions.ts
index 1e10f2397..a35bbcbdd 100644
--- a/packages/web/src/app/invite/actions.ts
+++ b/packages/web/src/app/invite/actions.ts
@@ -1,17 +1,15 @@
"use server";
-import { isServiceError } from "@/lib/utils";
-import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError";
-import { sew } from "@/middleware/sew";
+import { createAudit } from "@/ee/features/audit/audit";
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils";
-import { StatusCodes } from "http-status-codes";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { ErrorCode } from "@/lib/errorCodes";
+import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { sew } from "@/middleware/sew";
import { getAuthenticatedUser } from "@/middleware/withAuth";
import { __unsafePrisma } from "@/prisma";
-import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
-import { getAuditService } from "@/ee/features/audit/factory";
-
-const auditService = getAuditService();
+import { StatusCodes } from "http-status-codes";
// eslint-disable-next-line authz/require-auth-wrapper -- runs pre-org-membership; uses getAuthenticatedUser() directly since withAuth requires a user-to-org link this call is establishing
export const joinOrganization = async (inviteLinkId?: string) => sew(async () => {
@@ -57,7 +55,7 @@ export const joinOrganization = async (inviteLinkId?: string) => sew(async () =>
return addUserToOrgRes;
}
- await auditService.createAudit({
+ await createAudit({
action: "org.member_added",
actor: { id: user.id, type: "user" },
target: { id: user.id, type: "user" },
@@ -95,7 +93,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
}
const failAuditCallback = async (error: string) => {
- await auditService.createAudit({
+ await createAudit({
action: "user.invite_accept_failed",
actor: {
id: user.id,
@@ -112,7 +110,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
});
};
- const hasAvailability = await orgHasAvailability();
+ const hasAvailability = await orgHasAvailability(invite.org.id);
if (!hasAvailability) {
await failAuditCallback("Organization is at max capacity");
return {
@@ -134,7 +132,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
return addUserToOrgRes;
}
- await auditService.createAudit({
+ await createAudit({
action: "user.invite_accepted",
actor: {
id: user.id,
@@ -147,7 +145,7 @@ export const redeemInvite = async (inviteId: string): Promise<{ success: boolean
}
});
- await auditService.createAudit({
+ await createAudit({
action: "org.member_added",
actor: { id: user.id, type: "user" },
target: { id: user.id, type: "user" },
@@ -205,4 +203,3 @@ export const getInviteInfo = async (inviteId: string) => sew(async () => {
}
};
});
-
diff --git a/packages/web/src/app/invite/page.tsx b/packages/web/src/app/invite/page.tsx
index 4081ccc88..7bd615a61 100644
--- a/packages/web/src/app/invite/page.tsx
+++ b/packages/web/src/app/invite/page.tsx
@@ -29,7 +29,7 @@ export default async function InvitePage(props: InvitePageProps) {
const session = await auth();
if (!session) {
- const providers = getIdentityProviderMetadata();
+ const providers = await getIdentityProviderMetadata();
return
;
}
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index 71a90e2a8..24365f073 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Script from "next/script";
import "./globals.css";
import { ThemeProvider } from "next-themes";
+import { ProgressBarProvider } from "./progressBarProvider";
import { QueryClientProvider } from "./queryClientProvider";
import { PostHogProvider } from "./posthogProvider";
import { Toaster } from "@/components/ui/toaster";
@@ -9,59 +10,60 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { SessionProvider } from "next-auth/react";
import { env, SOURCEBOT_VERSION } from "@sourcebot/shared";
import { PlanProvider } from "@/features/entitlements/planProvider";
-import { getEntitlements } from "@sourcebot/shared";
+import { getEntitlements } from "@/lib/entitlements";
export const metadata: Metadata = {
- metadataBase: env.AUTH_URL ? new URL(env.AUTH_URL) : undefined,
- // Using the title.template will allow child pages to set the title
- // while keeping a consistent suffix.
- title: {
- default: "Sourcebot",
- template: "%s | Sourcebot",
- },
- description:
- "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.",
- manifest: "/manifest.json",
+ metadataBase: env.AUTH_URL ? new URL(env.AUTH_URL) : undefined,
+ // Using the title.template will allow child pages to set the title
+ // while keeping a consistent suffix.
+ title: {
+ default: "Sourcebot",
+ template: "%s | Sourcebot",
+ },
+ description:
+ "Sourcebot is a self-hosted code understanding tool. Ask questions about your codebase and get rich Markdown answers with inline citations.",
+ manifest: "/manifest.json",
};
-export default function RootLayout({
+export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
+ const entitlements = await getEntitlements();
+
return (
-
- {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_SCAN === 'true' && (
-
- )}
-
- {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_GRAB === 'true' && (
-
- )}
- {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_GRAB === 'true' && (
-
- )}
-
+
+ {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_SCAN === 'true' && (
+
+ )}
+ {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_GRAB === 'true' && (
+
+ )}
+ {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_GRAB === 'true' && (
+
+ )}
+
-
+
- {children}
+
+ {children}
+
diff --git a/packages/web/src/app/login/page.tsx b/packages/web/src/app/login/page.tsx
index c827e6f2d..20b816fb8 100644
--- a/packages/web/src/app/login/page.tsx
+++ b/packages/web/src/app/login/page.tsx
@@ -5,9 +5,8 @@ import { Footer } from "@/app/components/footer";
import { getIdentityProviderMetadata } from "@/lib/identityProviders";
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { __unsafePrisma } from "@/prisma";
-import { getAnonymousAccessStatus } from "@/actions";
-import { isServiceError } from "@/lib/utils";
import { env } from "@sourcebot/shared";
+import { isAnonymousAccessEnabled } from "@/lib/entitlements";
interface LoginProps {
searchParams: Promise<{
@@ -28,9 +27,8 @@ export default async function Login(props: LoginProps) {
return redirect("/onboard");
}
- const providers = getIdentityProviderMetadata();
- const anonymousAccessStatus = await getAnonymousAccessStatus();
- const isAnonymousAccessEnabled = !isServiceError(anonymousAccessStatus) && anonymousAccessStatus;
+ const providers = await getIdentityProviderMetadata();
+ const anonymousAccessEnabled = await isAnonymousAccessEnabled();
return (
@@ -40,7 +38,7 @@ export default async function Login(props: LoginProps) {
error={searchParams.error}
providers={providers}
context="login"
- isAnonymousAccessEnabled={isAnonymousAccessEnabled}
+ isAnonymousAccessEnabled={anonymousAccessEnabled}
hideSecurityNotice={env.EXPERIMENT_ASK_GH_ENABLED === 'true'}
/>
diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx
index b4a1f8391..8015047a0 100644
--- a/packages/web/src/app/not-found.tsx
+++ b/packages/web/src/app/not-found.tsx
@@ -1,7 +1,19 @@
-import { PageNotFound } from "./(app)/components/pageNotFound";
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
export default function NotFoundPage() {
return (
-
+
+
+
+
404
+
+
Page not found
+
+
+
)
}
\ No newline at end of file
diff --git a/packages/web/src/app/oauth/authorize/page.tsx b/packages/web/src/app/oauth/authorize/page.tsx
index cc816fc3f..ae58ee585 100644
--- a/packages/web/src/app/oauth/authorize/page.tsx
+++ b/packages/web/src/app/oauth/authorize/page.tsx
@@ -2,7 +2,7 @@ import { auth } from '@/auth';
import { LogoutEscapeHatch } from '@/app/components/logoutEscapeHatch';
import { ConsentScreen } from './components/consentScreen';
import { __unsafePrisma } from '@/prisma';
-import { hasEntitlement } from '@sourcebot/shared';
+import { hasEntitlement } from '@/lib/entitlements';
import { redirect } from 'next/navigation';
export const dynamic = 'force-dynamic';
@@ -20,7 +20,7 @@ interface AuthorizePageProps {
}
export default async function AuthorizePage({ searchParams }: AuthorizePageProps) {
- if (!hasEntitlement('oauth')) {
+ if (!await hasEntitlement('oauth')) {
return ;
}
diff --git a/packages/web/src/app/onboard/page.tsx b/packages/web/src/app/onboard/page.tsx
index b780dfbbb..8002262b5 100644
--- a/packages/web/src/app/onboard/page.tsx
+++ b/packages/web/src/app/onboard/page.tsx
@@ -15,8 +15,8 @@ import { OrgRole } from "@sourcebot/db";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { redirect } from "next/navigation";
import { BetweenHorizontalStart, Brain, GitBranchIcon, LockIcon } from "lucide-react";
-import { hasEntitlement } from "@sourcebot/shared";
import { env } from "@sourcebot/shared";
+import { hasEntitlement } from "@/lib/entitlements";
import { GcpIapAuth } from "@/app/(app)/components/gcpIapAuth";
interface OnboardingProps {
@@ -40,7 +40,7 @@ interface ResourceCard {
export default async function Onboarding(props: OnboardingProps) {
const searchParams = await props.searchParams;
- const providers = getIdentityProviderMetadata();
+ const providers = await getIdentityProviderMetadata();
const org = await __unsafePrisma.org.findUnique({ where: { id: SINGLE_TENANT_ORG_ID } });
const session = await auth();
@@ -309,7 +309,7 @@ export default async function Onboarding(props: OnboardingProps) {
{" "}
or{" "}
{
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/web/src/app/signup/page.tsx b/packages/web/src/app/signup/page.tsx
index a4a78ff1c..8961e32e0 100644
--- a/packages/web/src/app/signup/page.tsx
+++ b/packages/web/src/app/signup/page.tsx
@@ -6,8 +6,7 @@ import { getIdentityProviderMetadata } from "@/lib/identityProviders";
import { createLogger, env } from "@sourcebot/shared";
import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { __unsafePrisma } from "@/prisma";
-import { getAnonymousAccessStatus } from "@/actions";
-import { isServiceError } from "@/lib/utils";
+import { isAnonymousAccessEnabled } from "@/lib/entitlements";
const logger = createLogger('signup-page');
@@ -31,9 +30,8 @@ export default async function Signup(props: LoginProps) {
return redirect("/onboard");
}
- const providers = getIdentityProviderMetadata();
- const anonymousAccessStatus = await getAnonymousAccessStatus();
- const isAnonymousAccessEnabled = !isServiceError(anonymousAccessStatus) && anonymousAccessStatus;
+ const providers = await getIdentityProviderMetadata();
+ const anonymousAccessEnabled = await isAnonymousAccessEnabled();
return (
@@ -43,7 +41,7 @@ export default async function Signup(props: LoginProps) {
error={searchParams.error}
providers={providers}
context="signup"
- isAnonymousAccessEnabled={isAnonymousAccessEnabled}
+ isAnonymousAccessEnabled={anonymousAccessEnabled}
hideSecurityNotice={env.EXPERIMENT_ASK_GH_ENABLED === 'true'}
/>
diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts
index 467ab6fa1..5da6cd06a 100644
--- a/packages/web/src/auth.ts
+++ b/packages/web/src/auth.ts
@@ -14,14 +14,13 @@ import { render } from '@react-email/render';
import MagicLinkEmail from './emails/magicLinkEmail';
import bcrypt from 'bcryptjs';
import { getEEIdentityProviders } from '@/ee/features/sso/sso';
-import { hasEntitlement } from '@sourcebot/shared';
+import { hasEntitlement } from '@/lib/entitlements';
import { onCreateUser } from '@/lib/authUtils';
-import { getAuditService } from '@/ee/features/audit/factory';
+import { createAudit } from '@/ee/features/audit/audit';
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
import { EncryptedPrismaAdapter, encryptAccountData } from '@/lib/encryptedPrismaAdapter';
-
-const auditService = getAuditService();
-const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : [];
+import { getAnonymousId } from '@/lib/anonymousId';
+import { captureEvent } from '@/lib/posthog';
export const runtime = 'nodejs';
@@ -53,8 +52,11 @@ declare module 'next-auth/jwt' {
}
}
-export const getProviders = () => {
- const providers: IdentityProvider[] = [...eeIdentityProviders];
+export const getProviders = async () => {
+ const hasSSOEntitlement = await hasEntitlement("sso");
+ const providers: IdentityProvider[] = [
+ ...(hasSSOEntitlement ? await getEEIdentityProviders() : []),
+ ];
const smtpConnectionUrl = getSMTPConnectionURL();
if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS && env.AUTH_EMAIL_CODE_LOGIN_ENABLED === 'true') {
@@ -197,7 +199,29 @@ const nextAuthResult = NextAuth({
}
if (user.id) {
- await auditService.createAudit({
+ // Claim any anonymous chats created before sign-in.
+ const anonymousId = await getAnonymousId();
+ if (anonymousId) {
+ const result = await __unsafePrisma.chat.updateMany({
+ where: {
+ orgId: SINGLE_TENANT_ORG_ID,
+ anonymousCreatorId: anonymousId,
+ createdById: null,
+ },
+ data: {
+ createdById: user.id,
+ anonymousCreatorId: null,
+ },
+ });
+
+ if (result.count > 0) {
+ await captureEvent('wa_anonymous_chats_claimed', {
+ claimedCount: result.count,
+ });
+ }
+ }
+
+ await createAudit({
action: "user.signed_in",
actor: {
id: user.id,
@@ -214,7 +238,7 @@ const nextAuthResult = NextAuth({
signOut: async (message) => {
const token = message as { token: { userId: string } | null };
if (token?.token?.userId) {
- await auditService.createAudit({
+ await createAudit({
action: "user.signed_out",
actor: {
id: token.token.userId,
@@ -302,7 +326,7 @@ const nextAuthResult = NextAuth({
return session;
},
},
- providers: getProviders().map((provider) => provider.provider),
+ providers: (await getProviders()).map((provider) => provider.provider),
pages: {
signIn: "/login",
// We set redirect to false in signInOptions so we can pass the email is as a param
@@ -348,7 +372,7 @@ export const auth = cache(async (): Promise => {
* Returns the issuer URL for a given auth.js account
*/
const getIssuerUrlForAccount = async (account: { provider: string; }) => {
- const providers = getProviders();
+ const providers = await getProviders();
const matchingProvider = providers.find((provider) => {
if (typeof provider.provider === "function") {
const providerInfo = provider.provider();
diff --git a/packages/web/src/components/ui/data-table.tsx b/packages/web/src/components/ui/data-table.tsx
deleted file mode 100644
index 26b6417fd..000000000
--- a/packages/web/src/components/ui/data-table.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-"use client"
-
-import {
- ColumnDef,
- ColumnFiltersState,
- SortingState,
- flexRender,
- getCoreRowModel,
- getFilteredRowModel,
- getPaginationRowModel,
- getSortedRowModel,
- useReactTable,
-} from "@tanstack/react-table"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import * as React from "react"
-
-interface DataTableProps {
- columns: ColumnDef[]
- data: TData[]
- searchKey: string
- searchPlaceholder?: string,
- headerActions?: React.ReactNode,
-}
-
-export function DataTable({
- columns,
- data,
- searchKey,
- searchPlaceholder,
- headerActions,
-
-}: DataTableProps) {
- const [sorting, setSorting] = React.useState([])
- const [columnFilters, setColumnFilters] = React.useState(
- []
- )
-
- const table = useReactTable({
- data,
- columns,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- onSortingChange: setSorting,
- getSortedRowModel: getSortedRowModel(),
- onColumnFiltersChange: setColumnFilters,
- getFilteredRowModel: getFilteredRowModel(),
- autoResetPageIndex: false,
- state: {
- sorting,
- columnFilters,
- },
- })
-
- return (
-
-
-
- table.getColumn(searchKey)?.setFilterValue(event.target.value)
- }
- className="max-w-sm"
- />
- {/*
- TODO(auth): Combine this logic with existing add repo button logic in AddRepoButton component
- Show a button on the demo site that allows users to add new repositories
- by updating the demo-site-config.json file and opening a PR.
- */}
- {headerActions}
-
-
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => {
- return (
-
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
-
- )
- })}
-
- ))}
-
-
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map((row) => (
-
- {row.getVisibleCells().map((cell) => (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- ))}
-
- ))
- ) : (
-
-
- No results.
-
-
- )}
-
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/web/src/components/ui/sidebar.tsx b/packages/web/src/components/ui/sidebar.tsx
index 9e5d91636..66f27eee4 100644
--- a/packages/web/src/components/ui/sidebar.tsx
+++ b/packages/web/src/components/ui/sidebar.tsx
@@ -2,15 +2,21 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
-import { VariantProps, cva } from "class-variance-authority"
+import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
-import { useIsMobile } from "@/components/hooks/use-mobile"
+import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
-import { Sheet, SheetContent } from "@/components/ui/sheet"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
@@ -24,9 +30,9 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
-const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+const SIDEBAR_KEYBOARD_SHORTCUT = "["
-type SidebarContext = {
+type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
@@ -36,7 +42,7 @@ type SidebarContext = {
toggleSidebar: () => void
}
-const SidebarContext = React.createContext(null)
+const SidebarContext = React.createContext(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
@@ -99,9 +105,18 @@ const SidebarProvider = React.forwardRef<
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
+ const target = event.target as HTMLElement
+ if (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement ||
+ target.isContentEditable
+ ) {
+ return
+ }
+
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
- (event.metaKey || event.ctrlKey)
+ !(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
@@ -116,7 +131,7 @@ const SidebarProvider = React.forwardRef<
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
- const contextValue = React.useMemo(
+ const contextValue = React.useMemo(
() => ({
state,
open,
@@ -206,6 +221,10 @@ const Sidebar = React.forwardRef<
}
side={side}
>
+
+ Sidebar
+ Displays the mobile sidebar.
+
{children}
@@ -215,7 +234,7 @@ const Sidebar = React.forwardRef<
return (
svg]:size-4 [&>svg]:shrink-0",
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
@@ -631,7 +650,7 @@ const SidebarMenuBadge = React.forwardRef<
ref={ref}
data-sidebar="menu-badge"
className={cn(
- "absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
+ "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
@@ -659,7 +678,7 @@ const SidebarMenuSkeleton = React.forwardRef<
{showIcon && (
@@ -669,7 +688,7 @@ const SidebarMenuSkeleton = React.forwardRef<
/>
)}
->(({ className, ...props }, ref) => (
-
+ React.HTMLAttributes
& { wrapperClassName?: string }
+>(({ className, wrapperClassName, ...props }, ref) => (
+
=> sew(() =>
withAuth(async ({ org, role, prisma }) =>
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
- if (!hasEntitlement("analytics")) {
+ if (!await hasEntitlement("analytics")) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
diff --git a/packages/web/src/ee/features/analytics/analyticsContent.tsx b/packages/web/src/ee/features/analytics/analyticsContent.tsx
index f408c92d3..91e62254a 100644
--- a/packages/web/src/ee/features/analytics/analyticsContent.tsx
+++ b/packages/web/src/ee/features/analytics/analyticsContent.tsx
@@ -442,7 +442,7 @@ export function AnalyticsContent() {
if (isPending) {
return (
-
+
)
@@ -450,7 +450,7 @@ export function AnalyticsContent() {
if (isError) {
return (
-
+
diff --git a/packages/web/src/ee/features/analytics/analyticsEntitlementMessage.tsx b/packages/web/src/ee/features/analytics/analyticsEntitlementMessage.tsx
index 8610bd40d..2f1f3926d 100644
--- a/packages/web/src/ee/features/analytics/analyticsEntitlementMessage.tsx
+++ b/packages/web/src/ee/features/analytics/analyticsEntitlementMessage.tsx
@@ -7,7 +7,7 @@ import { BarChart3, Mail } from "lucide-react"
export function AnalyticsEntitlementMessage() {
return (
-
+
diff --git a/packages/web/src/ee/features/audit/actions.ts b/packages/web/src/ee/features/audit/actions.ts
index a1650a035..198a08fad 100644
--- a/packages/web/src/ee/features/audit/actions.ts
+++ b/packages/web/src/ee/features/audit/actions.ts
@@ -1,7 +1,7 @@
"use server";
import { sew } from "@/middleware/sew";
-import { getAuditService } from "@/ee/features/audit/factory";
+import { createAudit } from "@/ee/features/audit/audit";
import { ErrorCode } from "@/lib/errorCodes";
import { ServiceError } from "@/lib/serviceError";
import { withAuth } from "@/middleware/withAuth";
@@ -11,12 +11,11 @@ import { StatusCodes } from "http-status-codes";
import { AuditEvent } from "./types";
import { OrgRole } from "@sourcebot/db";
-const auditService = getAuditService();
const logger = createLogger('audit-utils');
export const createAuditAction = async (event: Omit
) => sew(async () =>
withAuth(async ({ user, org }) => {
- await auditService.createAudit({
+ await createAudit({
...event,
orgId: org.id,
actor: { id: user.id, type: "user" },
@@ -59,7 +58,7 @@ export const fetchAuditRecords = async (params: FetchAuditRecordsParams) => sew(
prisma.audit.count({ where }),
]);
- await auditService.createAudit({
+ await createAudit({
action: "audit.fetch",
actor: {
id: user.id,
diff --git a/packages/web/src/ee/features/audit/audit.ts b/packages/web/src/ee/features/audit/audit.ts
new file mode 100644
index 000000000..a30aea8fa
--- /dev/null
+++ b/packages/web/src/ee/features/audit/audit.ts
@@ -0,0 +1,34 @@
+import { __unsafePrisma } from '@/prisma';
+import { hasEntitlement } from '@/lib/entitlements';
+import { env, createLogger, SOURCEBOT_VERSION } from '@sourcebot/shared';
+import { AuditEvent } from '@/ee/features/audit/types';
+import { Audit } from '@prisma/client';
+
+const logger = createLogger('audit-service');
+
+export async function createAudit(event: Omit): Promise {
+ const auditLogsEnabled = (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') && await hasEntitlement("audit");
+ if (!auditLogsEnabled) {
+ return null;
+ }
+
+ try {
+ const audit = await __unsafePrisma.audit.create({
+ data: {
+ action: event.action,
+ actorId: event.actor.id,
+ actorType: event.actor.type,
+ targetId: event.target.id,
+ targetType: event.target.type,
+ sourcebotVersion: SOURCEBOT_VERSION,
+ metadata: event.metadata,
+ orgId: event.orgId,
+ },
+ });
+
+ return audit;
+ } catch (error) {
+ logger.error(`Error creating audit event: ${error}`, { event });
+ return null;
+ }
+}
diff --git a/packages/web/src/ee/features/audit/auditService.ts b/packages/web/src/ee/features/audit/auditService.ts
deleted file mode 100644
index 9cb264108..000000000
--- a/packages/web/src/ee/features/audit/auditService.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { IAuditService, AuditEvent } from '@/ee/features/audit/types';
-import { __unsafePrisma } from '@/prisma';
-import { Audit } from '@prisma/client';
-import { createLogger, SOURCEBOT_VERSION } from '@sourcebot/shared';
-
-const logger = createLogger('audit-service');
-
-export class AuditService implements IAuditService {
- async createAudit(event: Omit): Promise {
- const sourcebotVersion = SOURCEBOT_VERSION;
-
- try {
- const audit = await __unsafePrisma.audit.create({
- data: {
- action: event.action,
- actorId: event.actor.id,
- actorType: event.actor.type,
- targetId: event.target.id,
- targetType: event.target.type,
- sourcebotVersion,
- metadata: event.metadata,
- orgId: event.orgId,
- },
- });
-
- return audit;
- } catch (error) {
- logger.error(`Error creating audit event: ${error}`, { event });
- return null;
- }
- }
-}
\ No newline at end of file
diff --git a/packages/web/src/ee/features/audit/factory.ts b/packages/web/src/ee/features/audit/factory.ts
deleted file mode 100644
index 9a2a43006..000000000
--- a/packages/web/src/ee/features/audit/factory.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { IAuditService } from '@/ee/features/audit/types';
-import { MockAuditService } from '@/ee/features/audit/mockAuditService';
-import { AuditService } from '@/ee/features/audit/auditService';
-import { hasEntitlement } from '@sourcebot/shared';
-import { env } from '@sourcebot/shared';
-
-let enterpriseService: IAuditService | undefined;
-
-export function getAuditService(): IAuditService {
- const auditLogsEnabled = (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') && hasEntitlement("audit");
- enterpriseService = enterpriseService ?? (auditLogsEnabled ? new AuditService() : new MockAuditService());
- return enterpriseService;
-}
\ No newline at end of file
diff --git a/packages/web/src/ee/features/audit/mockAuditService.ts b/packages/web/src/ee/features/audit/mockAuditService.ts
deleted file mode 100644
index 5e40d545a..000000000
--- a/packages/web/src/ee/features/audit/mockAuditService.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { IAuditService, AuditEvent } from '@/ee/features/audit/types';
-import { Audit } from '@prisma/client';
-
-export class MockAuditService implements IAuditService {
- async createAudit(_event: Omit): Promise {
- return null;
- }
-}
\ No newline at end of file
diff --git a/packages/web/src/ee/features/audit/types.ts b/packages/web/src/ee/features/audit/types.ts
index e79b6957f..b936f700b 100644
--- a/packages/web/src/ee/features/audit/types.ts
+++ b/packages/web/src/ee/features/audit/types.ts
@@ -1,15 +1,14 @@
import { z } from "zod";
-import { Audit } from "@prisma/client";
export const auditActorSchema = z.object({
- id: z.string(),
- type: z.enum(["user", "api_key"]),
+ id: z.string(),
+ type: z.enum(["user", "api_key"]),
})
export type AuditActor = z.infer;
export const auditTargetSchema = z.object({
- id: z.string(),
- type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat"]),
+ id: z.string(),
+ type: z.enum(["user", "org", "file", "api_key", "account_join_request", "invite", "chat"]),
})
export type AuditTarget = z.infer;
@@ -22,15 +21,11 @@ export const auditMetadataSchema = z.object({
export type AuditMetadata = z.infer;
export const auditEventSchema = z.object({
- action: z.string(),
- actor: auditActorSchema,
- target: auditTargetSchema,
- sourcebotVersion: z.string(),
- orgId: z.number(),
- metadata: auditMetadataSchema.optional()
+ action: z.string(),
+ actor: auditActorSchema,
+ target: auditTargetSchema,
+ sourcebotVersion: z.string(),
+ orgId: z.number(),
+ metadata: auditMetadataSchema.optional()
})
export type AuditEvent = z.infer;
-
-export interface IAuditService {
- createAudit(event: Omit): Promise;
-}
\ No newline at end of file
diff --git a/packages/web/src/ee/features/lighthouse/CLAUDE.md b/packages/web/src/ee/features/lighthouse/CLAUDE.md
new file mode 100644
index 000000000..c278378dc
--- /dev/null
+++ b/packages/web/src/ee/features/lighthouse/CLAUDE.md
@@ -0,0 +1,18 @@
+# Lighthouse Feature Guidelines
+
+## Keeping types in sync with the lighthouse service
+
+The Zod schemas in `types.ts` mirror the request/response schemas defined in the lighthouse service (`sourcebot-dev/lighthouse`, under `lambda/routes/`). They MUST stay in lockstep.
+
+Whenever you change a schema in `types.ts`, you MUST also update the corresponding schema in:
+
+```
+lighthouse: lambda/routes/.ts
+```
+
+Conversely, if a schema changes in the lighthouse service, update `types.ts` here to match.
+
+This applies to:
+- Adding, removing, or renaming fields on any `*RequestSchema` / `*ResponseSchema`.
+- Changing a field's type, nullability, or validation (e.g. `.optional()`, `.nullable()`, `.datetime()`).
+- Adding new route schemas.
diff --git a/packages/web/src/ee/features/lighthouse/actions.ts b/packages/web/src/ee/features/lighthouse/actions.ts
new file mode 100644
index 000000000..215807940
--- /dev/null
+++ b/packages/web/src/ee/features/lighthouse/actions.ts
@@ -0,0 +1,236 @@
+'use server';
+
+import { sew } from "@/middleware/sew";
+import { withAuth } from "@/middleware/withAuth";
+import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
+import { OrgRole } from "@sourcebot/db";
+import { ServiceError, ServiceErrorException } from "@/lib/serviceError";
+import { StatusCodes } from "http-status-codes";
+import { ErrorCode } from "@/lib/errorCodes";
+import { encryptActivationCode, decryptActivationCode, env } from "@sourcebot/shared";
+import { syncWithLighthouse } from "@/ee/features/lighthouse/servicePing";
+import { isServiceError } from "@/lib/utils";
+import { revalidatePath } from "next/cache";
+import { client } from "./client";
+import { Invoice } from "./types";
+
+export const activateLicense = async (activationCode: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ // Check if a license already exists
+ const existing = await prisma.license.findUnique({
+ where: { orgId: org.id },
+ });
+
+ if (existing) {
+ return {
+ statusCode: StatusCodes.CONFLICT,
+ errorCode: ErrorCode.UNEXPECTED_ERROR,
+ message: "A license already exists for this organization.",
+ } satisfies ServiceError;
+ }
+
+ await prisma.license.create({
+ data: {
+ orgId: org.id,
+ activationCode: encryptActivationCode(activationCode),
+ },
+ });
+
+ try {
+ // Bind the activation code to this install. This is the only
+ // call that mutates the binding on the Lighthouse side; the
+ // subsequent ping is pure read.
+ const activateResult = await client.activate({
+ activationCode,
+ installId: env.SOURCEBOT_INSTALL_ID,
+ });
+
+ if (isServiceError(activateResult)) {
+ throw new ServiceErrorException(activateResult);
+ }
+
+ // Immediately sync license data from Lighthouse.
+ await syncWithLighthouse(org.id);
+ } catch (e) {
+ // If activation or initial sync fails, remove the license record
+ await prisma.license.delete({
+ where: { orgId: org.id },
+ });
+
+ throw e;
+ }
+
+ // Invalidate the (app) layout so BannerSlot re-resolves with the
+ // new license.
+ revalidatePath('/settings/license', 'layout');
+
+ return { success: true };
+ })
+ )
+);
+
+export const refreshLicense = async (): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const existing = await prisma.license.findUnique({
+ where: { orgId: org.id },
+ });
+
+ if (!existing) {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.NOT_FOUND,
+ message: "No license found.",
+ } satisfies ServiceError;
+ }
+
+ await syncWithLighthouse(org.id);
+
+ return { success: true };
+ })
+ )
+);
+
+export const createCheckoutSession = async (requestTrial = false): Promise<{ url: string } | ServiceError> => sew(() =>
+ withAuth(async ({ user, org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ if (!user.email) {
+ return {
+ statusCode: StatusCodes.BAD_REQUEST,
+ errorCode: ErrorCode.UNEXPECTED_ERROR,
+ message: "User does not have an email address.",
+ } satisfies ServiceError;
+ }
+
+ const memberCount = await prisma.userToOrg.count({
+ where: {
+ orgId: org.id,
+ },
+ });
+
+ const result = await client.checkout({
+ email: user.email,
+ installId: env.SOURCEBOT_INSTALL_ID,
+ quantity: Math.max(memberCount, 1),
+ requestTrial,
+ successUrl: requestTrial
+ ? `${env.AUTH_URL}/settings/license?checkout=success&refresh=true&trial_used=true`
+ : `${env.AUTH_URL}/settings/license?checkout=success&refresh=true`,
+ cancelUrl: `${env.AUTH_URL}/settings/license?refresh=true`,
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { url: result.url };
+ })
+ )
+);
+
+export const deactivateLicense = async (): Promise<{ success: boolean } | ServiceError> => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const existing = await prisma.license.findUnique({
+ where: { orgId: org.id },
+ });
+
+ if (!existing) {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.NOT_FOUND,
+ message: "No license found.",
+ } satisfies ServiceError;
+ }
+
+ await prisma.license.delete({
+ where: { orgId: org.id },
+ });
+
+ return { success: true };
+ })
+ )
+);
+
+export const createPortalSession = async (): Promise<{ url: string } | ServiceError> => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const license = await prisma.license.findUnique({
+ where: { orgId: org.id },
+ });
+
+ if (!license) {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.NOT_FOUND,
+ message: "No license found.",
+ } satisfies ServiceError;
+ }
+
+ const activationCode = decryptActivationCode(license.activationCode);
+
+ const result = await client.portal({
+ activationCode,
+ // Forces a license resync on the return page so any changes
+ // made in the portal (e.g. payment method added) show up
+ // immediately instead of waiting for the next daily ping.
+ returnUrl: `${env.AUTH_URL}/settings/license?refresh=true`,
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ return { url: result.url };
+ })
+ )
+);
+
+export const getAllInvoices = async (): Promise => sew(() =>
+ withAuth(async ({ org, role, prisma }) =>
+ withMinimumOrgRole(role, OrgRole.OWNER, async () => {
+ const license = await prisma.license.findUnique({
+ where: { orgId: org.id },
+ });
+
+ if (!license) {
+ return {
+ statusCode: StatusCodes.NOT_FOUND,
+ errorCode: ErrorCode.NOT_FOUND,
+ message: "No license found.",
+ } satisfies ServiceError;
+ }
+
+ const activationCode = decryptActivationCode(license.activationCode);
+
+ const allInvoices: Invoice[] = [];
+ let startingAfter: string | undefined;
+ while (true) {
+ const result = await client.invoices({
+ activationCode,
+ limit: 100,
+ ...(startingAfter && { startingAfter }),
+ });
+
+ if (isServiceError(result)) {
+ return result;
+ }
+
+ allInvoices.push(...result.invoices);
+
+ if (!result.hasMore) {
+ break;
+ }
+
+ const lastInvoice = result.invoices[result.invoices.length - 1];
+ if (!lastInvoice) {
+ break;
+ }
+ startingAfter = lastInvoice.id;
+ }
+
+ return allInvoices;
+ })
+ )
+);
diff --git a/packages/web/src/ee/features/lighthouse/client.ts b/packages/web/src/ee/features/lighthouse/client.ts
new file mode 100644
index 000000000..82bfaee46
--- /dev/null
+++ b/packages/web/src/ee/features/lighthouse/client.ts
@@ -0,0 +1,106 @@
+import { fetchWithRetry, isServiceError } from "@/lib/utils";
+import { env } from "@sourcebot/shared";
+import {
+ ActivateRequest,
+ ActivateResponse,
+ activateResponseSchema,
+ CheckoutRequest,
+ CheckoutResponse,
+ checkoutResponseSchema,
+ InvoicesRequest,
+ InvoicesResponse,
+ invoicesResponseSchema,
+ PortalRequest,
+ PortalResponse,
+ portalResponseSchema,
+ ServicePingRequest,
+ ServicePingResponse,
+ servicePingResponseSchema,
+} from "./types";
+import { ServiceError } from "@/lib/serviceError";
+import { ErrorCode } from "@/lib/errorCodes";
+import { StatusCodes } from "http-status-codes";
+import { z } from "zod";
+
+export const client = {
+ activate: async (body: ActivateRequest): Promise => {
+ const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/activate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ return parseResponseBody(response, activateResponseSchema);
+ },
+
+ ping: async (body: ServicePingRequest): Promise => {
+ const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/ping`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ return parseResponseBody(response, servicePingResponseSchema);
+ },
+
+ checkout: async (body: CheckoutRequest): Promise => {
+ const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/checkout`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ return parseResponseBody(response, checkoutResponseSchema);
+ },
+
+ portal: async (body: PortalRequest): Promise => {
+ const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/portal`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ return parseResponseBody(response, portalResponseSchema);
+ },
+
+ invoices: async (body: InvoicesRequest): Promise => {
+ const response = await fetchWithRetry(`${env.SOURCEBOT_LIGHTHOUSE_URL}/invoices`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ return parseResponseBody(response, invoicesResponseSchema);
+ },
+}
+
+const parseResponseBody = async (
+ response: Response,
+ schema: T,
+): Promise | ServiceError> => {
+ let body: unknown;
+ try {
+ body = await response.json();
+ } catch (error) {
+ return {
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
+ errorCode: ErrorCode.INVALID_RESPONSE_BODY,
+ message: `Failed to parse response body as JSON: ${error instanceof Error ? error.message : String(error)}`,
+ };
+ }
+
+ if (isServiceError(body)) {
+ return body;
+ }
+
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ return {
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
+ errorCode: ErrorCode.INVALID_RESPONSE_BODY,
+ message: `Response body failed schema validation: ${parsed.error.message}`,
+ };
+ }
+
+ return parsed.data;
+}
diff --git a/packages/web/src/ee/features/lighthouse/servicePing.ts b/packages/web/src/ee/features/lighthouse/servicePing.ts
new file mode 100644
index 000000000..df48f824a
--- /dev/null
+++ b/packages/web/src/ee/features/lighthouse/servicePing.ts
@@ -0,0 +1,135 @@
+import { existsSync } from "fs";
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
+import { isServiceError } from "@/lib/utils";
+import { __unsafePrisma } from "@/prisma";
+import { createLogger, decryptActivationCode, env, SOURCEBOT_VERSION } from "@sourcebot/shared";
+import { client } from "./client";
+import { ServicePingRequest } from "./types";
+import { ServiceErrorException } from "@/lib/serviceError";
+
+const logger = createLogger('service-ping');
+
+const SERVICE_PING_INTERVAL_MS = 24 * 60 * 60 * 1000; // 1 day
+
+
+export const syncWithLighthouse = async (orgId: number) => {
+ // Look up the activation code from the License record
+ const license = await __unsafePrisma.license.findUnique({
+ where: { orgId },
+ });
+
+ const [userCount, repoCount] = await Promise.all([
+ __unsafePrisma.userToOrg.count({
+ where: {
+ orgId,
+ },
+ }),
+ __unsafePrisma.repo.count({
+ where: {
+ orgId,
+ },
+ }),
+ ]);
+
+ const activationCode = license?.activationCode
+ ? decryptActivationCode(license.activationCode)
+ : undefined;
+
+ const payload: ServicePingRequest = {
+ installId: env.SOURCEBOT_INSTALL_ID,
+ version: SOURCEBOT_VERSION,
+ userCount,
+ repoCount,
+ deploymentType: inferDeploymentType(),
+ isTelemetryEnabled: env.SOURCEBOT_TELEMETRY_DISABLED === 'false',
+ ...(activationCode && { activationCode }),
+ };
+
+ const response = await client.ping(payload);
+ if (isServiceError(response)) {
+ logger.error(`Service ping failed:\n ${JSON.stringify(response, null, 2)}`)
+
+ if (license) {
+ await __unsafePrisma.license.update({
+ where: { orgId },
+ data: { lastSyncErrorCode: response.errorCode },
+ });
+ }
+
+ throw new ServiceErrorException(response);
+ }
+
+ logger.info(`Service ping sent successfully`);
+
+ // If we have a license and Lighthouse returned license data, sync it
+ if (license && response.license) {
+ const {
+ entitlements,
+ seats,
+ status,
+ planName,
+ unitAmount,
+ currency,
+ interval,
+ intervalCount,
+ nextRenewalAt,
+ nextRenewalAmount,
+ cancelAt,
+ trialEnd,
+ hasPaymentMethod,
+ } = response.license;
+
+ await __unsafePrisma.license.update({
+ where: {
+ orgId
+ },
+ data: {
+ entitlements,
+ seats,
+ status,
+ planName,
+ unitAmount,
+ currency,
+ interval,
+ intervalCount,
+ nextRenewalAt: nextRenewalAt ? new Date(nextRenewalAt) : null,
+ nextRenewalAmount,
+ cancelAt: cancelAt ? new Date(cancelAt) : null,
+ trialEnd: trialEnd ? new Date(trialEnd) : null,
+ hasPaymentMethod,
+ lastSyncAt: new Date(),
+ lastSyncErrorCode: null,
+ },
+ });
+
+ if (trialEnd) {
+ await __unsafePrisma.org.update({
+ where: { id: orgId, trialUsedAt: null },
+ data: { trialUsedAt: new Date() },
+ }).catch(() => {
+ // No-op: the `where` matched zero rows because trialUsedAt
+ // was already set. Safe to ignore.
+ });
+ }
+
+ logger.info(`License synced: entitlements=${entitlements.join(',')}, seats=${seats}, status=${status}`);
+ }
+};
+
+export const startServicePingCronJob = () => {
+ syncWithLighthouse(SINGLE_TENANT_ORG_ID).catch(() => { /* ignore error */ })
+ setInterval(
+ () => syncWithLighthouse(SINGLE_TENANT_ORG_ID).catch(() => { /* ignore error */ }),
+ SERVICE_PING_INTERVAL_MS
+ );
+};
+
+const inferDeploymentType = (): string => {
+ if (process.env.KUBERNETES_SERVICE_HOST) {
+ return 'kubernetes';
+ }
+ if (existsSync('/.dockerenv')) {
+ return 'docker';
+ }
+ return 'other';
+};
diff --git a/packages/web/src/ee/features/lighthouse/types.ts b/packages/web/src/ee/features/lighthouse/types.ts
new file mode 100644
index 000000000..e4db85739
--- /dev/null
+++ b/packages/web/src/ee/features/lighthouse/types.ts
@@ -0,0 +1,92 @@
+import { z } from "zod";
+
+export const servicePingRequestSchema = z.object({
+ installId: z.string(),
+ version: z.string(),
+ userCount: z.number(),
+ repoCount: z.number(),
+ deploymentType: z.string(),
+ isTelemetryEnabled: z.boolean(),
+ activationCode: z.string().optional(),
+});
+export type ServicePingRequest = z.infer;
+
+export const activateRequestSchema = z.object({
+ activationCode: z.string(),
+ installId: z.string(),
+});
+export type ActivateRequest = z.infer;
+
+export const activateResponseSchema = z.object({
+ status: z.literal('ok'),
+ reactivationsRemaining: z.number().int(),
+});
+export type ActivateResponse = z.infer;
+
+export const servicePingResponseSchema = z.object({
+ license: z.object({
+ entitlements: z.string().array(),
+ seats: z.number(),
+ status: z.string(),
+ planName: z.string(),
+ unitAmount: z.number().int(),
+ currency: z.string(),
+ interval: z.string(),
+ intervalCount: z.number().int(),
+ nextRenewalAt: z.string().datetime().nullable(),
+ nextRenewalAmount: z.number().int().nullable(),
+ cancelAt: z.string().datetime().nullable(),
+ trialEnd: z.string().datetime().nullable(),
+ hasPaymentMethod: z.boolean(),
+ }).optional(),
+});
+export type ServicePingResponse = z.infer;
+
+export const checkoutRequestSchema = z.object({
+ email: z.string().email(),
+ installId: z.string(),
+ quantity: z.number().int().positive(),
+ requestTrial: z.boolean().default(false),
+ successUrl: z.string().url(),
+ cancelUrl: z.string().url(),
+});
+export type CheckoutRequest = z.infer;
+
+export const checkoutResponseSchema = z.object({
+ url: z.string(),
+});
+export type CheckoutResponse = z.infer;
+
+export const portalRequestSchema = z.object({
+ activationCode: z.string(),
+ returnUrl: z.string().url(),
+});
+export type PortalRequest = z.infer;
+
+export const portalResponseSchema = z.object({
+ url: z.string(),
+});
+export type PortalResponse = z.infer;
+
+export const invoiceSchema = z.object({
+ id: z.string(),
+ createdAt: z.string(),
+ amount: z.number().int(),
+ currency: z.string(),
+ status: z.string(),
+ hostedInvoiceUrl: z.string().nullable(),
+});
+export type Invoice = z.infer;
+
+export const invoicesRequestSchema = z.object({
+ activationCode: z.string(),
+ limit: z.number().int().positive().max(100).optional(),
+ startingAfter: z.string().optional(),
+});
+export type InvoicesRequest = z.infer;
+
+export const invoicesResponseSchema = z.object({
+ invoices: z.array(invoiceSchema),
+ hasMore: z.boolean(),
+});
+export type InvoicesResponse = z.infer;
diff --git a/packages/web/src/ee/features/sso/actions.ts b/packages/web/src/ee/features/sso/actions.ts
index d79466a6b..4a911852e 100644
--- a/packages/web/src/ee/features/sso/actions.ts
+++ b/packages/web/src/ee/features/sso/actions.ts
@@ -5,7 +5,8 @@ import { OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME } from "@/lib/constants";
import { withAuth } from "@/middleware/withAuth";
import { withMinimumOrgRole } from "@/middleware/withMinimumOrgRole";
import { OrgRole } from "@sourcebot/db";
-import { createLogger, env, hasEntitlement, IdentityProviderType, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
+import { createLogger, env, IdentityProviderType, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
+import { hasEntitlement } from "@/lib/entitlements";
import { cookies } from "next/headers";
const logger = createLogger('web-ee-sso-actions');
@@ -42,7 +43,7 @@ export const getLinkedAccounts = async () => sew(() =>
const permissionSyncEnabled =
env.PERMISSION_SYNC_ENABLED === 'true' &&
- hasEntitlement('permission-syncing');
+ await hasEntitlement('permission-syncing');
const accountsByProvider = new Map(accounts.map(a => [a.provider, a]));
const result: LinkedAccount[] = [];
diff --git a/packages/web/src/ee/features/sso/components/connectAccountsCard.tsx b/packages/web/src/ee/features/sso/components/connectAccountsCard.tsx
index 9ab1ab30b..f8302408c 100644
--- a/packages/web/src/ee/features/sso/components/connectAccountsCard.tsx
+++ b/packages/web/src/ee/features/sso/components/connectAccountsCard.tsx
@@ -1,7 +1,7 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
+import { LoadingButton } from "@/components/ui/loading-button";
import { skipOptionalProvidersLink } from "@/ee/features/sso/actions";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -21,11 +21,10 @@ export const ConnectAccountsCard = ({ linkedAccounts, callbackUrl }: ConnectAcco
setIsSkipping(true);
try {
await skipOptionalProvidersLink();
+ router.refresh();
} catch (error) {
console.error("Failed to skip optional providers:", error);
- } finally {
setIsSkipping(false);
- router.refresh()
}
};
@@ -43,27 +42,27 @@ export const ConnectAccountsCard = ({ linkedAccounts, callbackUrl }: ConnectAcco
You can manage your linked accounts later in Settings → Linked Accounts.
-
-
+
+
{accountLinkingProviders
.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
.map(account => (
-