diff --git a/apps/storybook/src/mockData/organizations.ts b/apps/storybook/src/mockData/organizations.ts index 6b4de19477..2555b67a99 100644 --- a/apps/storybook/src/mockData/organizations.ts +++ b/apps/storybook/src/mockData/organizations.ts @@ -28,6 +28,7 @@ function generateMember(): OrganizationMember { { id: randomId(rng, 'org'), name: `Team ${randomInt(rng, 1, 9)}`, + slug: `team-${randomInt(rng, 1, 9)}`, role: 'member', }, ] @@ -56,8 +57,8 @@ export function generateOrganization(): OrganizationWithMembers { ...base, name: `Company ${randomInt(rng, 0, 999)} ${companyType}`, childOrganizations: [ - { id: randomId(rng, 'org'), name: 'Platform Team' }, - { id: randomId(rng, 'org'), name: 'Product Team' }, + { id: randomId(rng, 'org'), name: 'Platform Team', slug: 'platform-team' }, + { id: randomId(rng, 'org'), name: 'Product Team', slug: 'product-team' }, ], members: Array.from({ length: randomInt(rng, 2, 7) }, generateMember), effectiveSsoPolicy: { diff --git a/apps/storybook/stories/OrganizationSwitcher.stories.tsx b/apps/storybook/stories/OrganizationSwitcher.stories.tsx index b43fa485a4..de9b827d06 100644 --- a/apps/storybook/stories/OrganizationSwitcher.stories.tsx +++ b/apps/storybook/stories/OrganizationSwitcher.stories.tsx @@ -13,16 +13,19 @@ type OrganizationSwitcherStoryProps = { const organizations: OrganizationSwitcherOrganization[] = [ { organizationId: 'org-kilo', + organizationSlug: 'kilo-code', organizationName: 'Kilo Code', role: 'owner', }, { organizationId: 'org-design', + organizationSlug: 'design-systems', organizationName: 'Design Systems', role: 'member', }, { organizationId: 'org-cloud', + organizationSlug: 'cloud-platform', organizationName: 'Cloud Platform', role: 'member', }, @@ -34,6 +37,10 @@ function OrganizationSwitcherStory({ }: OrganizationSwitcherStoryProps) { const [organizationId, setOrganizationId] = useState(initialOrganizationId); + const handleOrganizationSwitch = (organization: OrganizationSwitcherOrganization | null) => { + setOrganizationId(organization?.organizationId ?? null); + }; + return (
@@ -41,7 +48,7 @@ function OrganizationSwitcherStory({ organizationId={organizationId} organizations={organizations} isPending={isPending} - onOrganizationSwitch={setOrganizationId} + onOrganizationSwitch={handleOrganizationSwitch} />
diff --git a/apps/storybook/stories/Sidebar.stories.tsx b/apps/storybook/stories/Sidebar.stories.tsx index cd8d2c5a91..476a8b89f0 100644 --- a/apps/storybook/stories/Sidebar.stories.tsx +++ b/apps/storybook/stories/Sidebar.stories.tsx @@ -82,11 +82,13 @@ const mockUser = { const mockOrganizations = [ { organizationId: 'org-kilo', + organizationSlug: 'kilo-code', organizationName: 'Kilo Code', role: 'owner', }, { organizationId: 'org-design', + organizationSlug: 'design-systems', organizationName: 'Design Systems', role: 'member', }, diff --git a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx index 672deb8961..48d99acbd4 100644 --- a/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx +++ b/apps/web/src/app/(app)/cloud/sessions/SessionsPageContent.tsx @@ -46,7 +46,15 @@ const PLATFORM_OPTIONS: readonly { type PlatformFilterValue = 'all' | 'cloud-agent' | 'cli' | 'agent-manager' | 'gastown' | 'other'; -export function SessionsPageContent() { +type SessionsPageContentProps = { + organizationId?: string; + organizationRouteIdentifier?: string; +}; + +export function SessionsPageContent({ + organizationId, + organizationRouteIdentifier, +}: SessionsPageContentProps = {}) { const trpc = useTRPC(); const pathname = usePathname(); const [searchQuery, setSearchQuery] = useState(''); @@ -65,11 +73,12 @@ export function SessionsPageContent() { return () => clearTimeout(timer); }, [searchQuery]); - // Determine if we're in an organization context - const organizationId = pathname.match(/^\/organizations\/([^/]+)/)?.[1]; - // When in organization context, OrganizationTrialWrapper already provides PageContainer const shouldUsePageContainer = !organizationId; + const organizationPathIdentifier = + organizationRouteIdentifier ?? + organizationId ?? + pathname.match(/^\/organizations\/([^/]+)/)?.[1]; const isSearching = debouncedSearchQuery.trim().length > 0; @@ -242,7 +251,7 @@ export function SessionsPageContent() { setIsDialogOpen(false)} diff --git a/apps/web/src/app/(app)/components/AppSidebar.tsx b/apps/web/src/app/(app)/components/AppSidebar.tsx index 0496ffa13f..9f66d63659 100644 --- a/apps/web/src/app/(app)/components/AppSidebar.tsx +++ b/apps/web/src/app/(app)/components/AppSidebar.tsx @@ -2,14 +2,21 @@ import { useEffect, useRef } from 'react'; import { usePathname } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; import { useSidebar, type Sidebar } from '@/components/ui/sidebar'; -import { useUrlOrganizationId } from '@/hooks/useUrlOrganizationId'; +import { useUrlOrganizationIdentifier } from '@/hooks/useUrlOrganizationId'; +import { + findOrganizationByRouteIdentifier, + isUuidOrganizationRouteIdentifier, +} from '@/lib/organizations/organization-route-utils'; +import { useTRPC } from '@/lib/trpc/utils'; import PersonalAppSidebar from './PersonalAppSidebar'; import OrganizationAppSidebar from './OrganizationAppSidebar'; import { GastownTownSidebar } from '@/components/gastown/GastownTownSidebar'; import { WastelandSidebar } from '@/components/wasteland/WastelandSidebar'; const UUID = '[0-9a-f-]{36}'; +const ORG_ROUTE_IDENTIFIER = '[^/]+'; /** Extract the townId from a /gastown/[townId] pathname, or null. */ function extractGastownTownId(pathname: string): string | null { @@ -17,14 +24,21 @@ function extractGastownTownId(pathname: string): string | null { return match ? match[1] : null; } -/** Extract {orgId, townId} from an /organizations/[id]/gastown/[townId] pathname, or null. */ -function extractOrgGastownTownId(pathname: string): { orgId: string; townId: string } | null { - const match = pathname.match(new RegExp(`^/organizations/(${UUID})/gastown/(${UUID})`)); - return match ? { orgId: match[1], townId: match[2] } : null; +/** Extract {orgIdentifier, townId} from an /organizations/[id]/gastown/[townId] pathname, or null. */ +function extractOrgGastownTownId( + pathname: string +): { orgIdentifier: string; townId: string } | null { + const match = pathname.match( + new RegExp(`^/organizations/(${ORG_ROUTE_IDENTIFIER})/gastown/(${UUID})`) + ); + return match ? { orgIdentifier: decodeURIComponent(match[1]), townId: match[2] } : null; } function isKiloClawNewPath(pathname: string): boolean { - return pathname === '/claw/new' || new RegExp(`^/organizations/${UUID}/claw/new$`).test(pathname); + return ( + pathname === '/claw/new' || + new RegExp(`^/organizations/${ORG_ROUTE_IDENTIFIER}/claw/new$`).test(pathname) + ); } /** Extract the wastelandId from a /wasteland/[wastelandId] pathname, or null. */ @@ -33,19 +47,60 @@ function extractWastelandId(pathname: string): string | null { return match ? match[1] : null; } -/** Extract {orgId, wastelandId} from an /organizations/[id]/wasteland/[wastelandId] pathname, or null. */ -function extractOrgWastelandId(pathname: string): { orgId: string; wastelandId: string } | null { - const match = pathname.match(new RegExp(`^/organizations/(${UUID})/wasteland/(${UUID})`)); - return match ? { orgId: match[1], wastelandId: match[2] } : null; +/** Extract {orgIdentifier, wastelandId} from an /organizations/[id]/wasteland/[wastelandId] pathname, or null. */ +function extractOrgWastelandId( + pathname: string +): { orgIdentifier: string; wastelandId: string } | null { + const match = pathname.match( + new RegExp(`^/organizations/(${ORG_ROUTE_IDENTIFIER})/wasteland/(${UUID})`) + ); + return match ? { orgIdentifier: decodeURIComponent(match[1]), wastelandId: match[2] } : null; } export default function AppSidebar(props: React.ComponentProps) { - const currentOrgId = useUrlOrganizationId(); + const trpc = useTRPC(); + const currentOrgIdentifier = useUrlOrganizationIdentifier(); const pathname = usePathname(); const { open, setOpenMobile, setOpenTransient } = useSidebar(); const previousSidebarOpen = useRef(null); const currentSidebarOpen = useRef(open); const sidebarActions = useRef({ setOpenMobile, setOpenTransient }); + const { data: organizations = [] } = useQuery( + trpc.organizations.list.queryOptions(undefined, { + enabled: Boolean(currentOrgIdentifier), + trpc: { + context: { + skipBatch: true, + }, + }, + }) + ); + const currentOrgFromList = findOrganizationByRouteIdentifier( + organizations.map(org => ({ + id: org.organizationId, + slug: org.organizationSlug, + })), + currentOrgIdentifier + ); + const { data: resolvedCurrentOrg } = useQuery( + trpc.organizations.resolveRouteIdentifier.queryOptions( + { routeIdentifier: currentOrgIdentifier ?? '' }, + { + enabled: Boolean(currentOrgIdentifier && !currentOrgFromList), + trpc: { + context: { + skipBatch: true, + }, + }, + } + ) + ); + const currentOrgId = + currentOrgFromList?.id ?? + resolvedCurrentOrg?.id ?? + (currentOrgIdentifier && isUuidOrganizationRouteIdentifier(currentOrgIdentifier) + ? currentOrgIdentifier + : null); useEffect(() => { currentSidebarOpen.current = open; @@ -80,7 +135,7 @@ export default function AppSidebar(props: React.ComponentProps) // Org gastown town — show the same sidebar with org-prefixed paths const orgGastown = extractOrgGastownTownId(pathname); if (orgGastown) { - const orgBase = `/organizations/${orgGastown.orgId}`; + const orgBase = `/organizations/${orgGastown.orgIdentifier}`; return ( ) // Org wasteland — show the same sidebar with org-prefixed paths const orgWasteland = extractOrgWastelandId(pathname); if (orgWasteland) { - const orgBase = `/organizations/${orgWasteland.orgId}`; + const orgBase = `/organizations/${orgWasteland.orgIdentifier}`; return ( & { organizationId: string; @@ -62,6 +63,9 @@ export default function OrganizationAppSidebar({ // Get current organization role and data const currentOrg = organizationData; + const organizationRouteIdentifier = currentOrg + ? getOrganizationRouteIdentifier(currentOrg) + : organizationId; const actualRole = currentOrg?.members.find(member => { if (member.status !== 'active') return false; @@ -111,19 +115,19 @@ export default function OrganizationAppSidebar({ { title: 'Welcome', icon: Sparkles, - url: `/organizations/${organizationId}/welcome`, + url: `/organizations/${organizationRouteIdentifier}/welcome`, }, ] : []), { title: 'Organization', icon: Building, - url: `/organizations/${organizationId}`, + url: `/organizations/${organizationRouteIdentifier}`, }, { title: 'Usage', icon: ChartColumnIncreasing, - url: `/organizations/${organizationId}/usage-details`, + url: `/organizations/${organizationRouteIdentifier}/usage-details`, }, ]; @@ -139,7 +143,7 @@ export default function OrganizationAppSidebar({ { title: 'Chat', icon: MessageSquare, - url: `/organizations/${organizationId}/claw/chat`, + url: `/organizations/${organizationRouteIdentifier}/claw/chat`, }, // Agent management is admin-only for now. ...(user?.is_admin @@ -147,19 +151,19 @@ export default function OrganizationAppSidebar({ { title: 'Agents', icon: Bot, - url: `/organizations/${organizationId}/claw/agents`, + url: `/organizations/${organizationRouteIdentifier}/claw/agents`, }, ] : []), { title: 'Settings', icon: Settings, - url: `/organizations/${organizationId}/claw/settings`, + url: `/organizations/${organizationRouteIdentifier}/claw/settings`, }, { title: "What's New", icon: Sparkles, - url: `/organizations/${organizationId}/claw/changelog`, + url: `/organizations/${organizationRouteIdentifier}/claw/changelog`, }, ]; @@ -175,24 +179,24 @@ export default function OrganizationAppSidebar({ { title: 'App Builder', icon: Plus, - url: `/organizations/${organizationId}/app-builder`, + url: `/organizations/${organizationRouteIdentifier}/app-builder`, }, ] : []), { title: 'Cloud Agent', icon: Cloud, - url: `/organizations/${organizationId}/cloud`, + url: `/organizations/${organizationRouteIdentifier}/cloud`, }, { title: 'Sessions', icon: List, - url: `/organizations/${organizationId}/cloud/sessions`, + url: `/organizations/${organizationRouteIdentifier}/cloud/sessions`, }, { title: 'Webhooks / Triggers', icon: Webhook, - url: `/organizations/${organizationId}/cloud/triggers`, + url: `/organizations/${organizationRouteIdentifier}/cloud/triggers`, }, // Gastown requires non-billing_manager role; hide for billing-only users ...(currentRole !== 'billing_manager' @@ -200,28 +204,32 @@ export default function OrganizationAppSidebar({ { title: 'Gas Town', icon: Bot, - url: `/organizations/${organizationId}/gastown`, + url: `/organizations/${organizationRouteIdentifier}/gastown`, }, ] : []), { title: 'Code Reviewer', icon: Bot, - url: `/organizations/${organizationId}/code-reviews`, + url: `/organizations/${organizationRouteIdentifier}/code-reviews`, }, { title: 'Security Agent', icon: Shield, - url: `/organizations/${organizationId}/security-agent`, + url: `/organizations/${organizationRouteIdentifier}/security-agent`, }, ...(isAutoTriageFeatureEnabled || isDevelopment ? [ { title: 'Auto Triage', icon: ListChecks, - url: `/organizations/${organizationId}/auto-triage`, + url: `/organizations/${organizationRouteIdentifier}/auto-triage`, + }, + { + title: 'Auto Fix', + icon: Wrench, + url: `/organizations/${organizationRouteIdentifier}/auto-fix`, }, - { title: 'Auto Fix', icon: Wrench, url: `/organizations/${organizationId}/auto-fix` }, ] : []), ...(ENABLE_DEPLOY_FEATURE @@ -229,7 +237,7 @@ export default function OrganizationAppSidebar({ { title: 'Deploy', icon: Rocket, - url: `/organizations/${organizationId}/deploy`, + url: `/organizations/${organizationRouteIdentifier}/deploy`, }, ] : []), @@ -238,7 +246,7 @@ export default function OrganizationAppSidebar({ { title: 'Managed Indexing', icon: Database, - url: `/organizations/${organizationId}/code-indexing`, + url: `/organizations/${organizationRouteIdentifier}/code-indexing`, }, ] : []), @@ -247,7 +255,7 @@ export default function OrganizationAppSidebar({ { title: 'MCP Gateway', icon: Cable, - url: `/organizations/${organizationId}/cloud/mcp-gateway`, + url: `/organizations/${organizationRouteIdentifier}/cloud/mcp-gateway`, }, ] : []), @@ -265,7 +273,7 @@ export default function OrganizationAppSidebar({ { title: 'Subscriptions', icon: Users, - url: `/organizations/${organizationId}/subscriptions`, + url: `/organizations/${organizationRouteIdentifier}/subscriptions`, }, ] : []), @@ -274,7 +282,7 @@ export default function OrganizationAppSidebar({ { title: 'Integrations', icon: Cable, - url: `/organizations/${organizationId}/integrations`, + url: `/organizations/${organizationRouteIdentifier}/integrations`, }, ] : []), @@ -283,21 +291,21 @@ export default function OrganizationAppSidebar({ { title: 'Model Access', icon: Layers, - url: `/organizations/${organizationId}/providers-and-models`, + url: `/organizations/${organizationRouteIdentifier}/providers-and-models`, }, ] : []), { title: 'Custom Modes', icon: Sliders, - url: `/organizations/${organizationId}/custom-modes`, + url: `/organizations/${organizationRouteIdentifier}/custom-modes`, }, ...(hasOwnerLevelAccess && currentOrg?.plan === 'enterprise' ? [ { title: 'Audit Logs', icon: Activity, - url: `/organizations/${organizationId}/audit-logs`, + url: `/organizations/${organizationRouteIdentifier}/audit-logs`, }, ] : []), @@ -306,25 +314,28 @@ export default function OrganizationAppSidebar({ { title: 'Invoices', icon: CreditCard, - url: `/organizations/${organizationId}/payment-details`, + url: `/organizations/${organizationRouteIdentifier}/payment-details`, }, { title: 'Bring Your Own Key (BYOK)', icon: Key, - url: `/organizations/${organizationId}/byok`, + url: `/organizations/${organizationRouteIdentifier}/byok`, }, ] : []), ]; - const kiloClawBaseUrl = `/organizations/${organizationId}/claw`; + const kiloClawBaseUrl = `/organizations/${organizationRouteIdentifier}/claw`; + const kiloClawCanonicalBaseUrl = `/organizations/${organizationId}/claw`; const kiloClawInstanceState = kiloClawNavStateQuery.isSuccess ? kiloClawNavStateQuery.data.hasActiveInstance ? 'present' : 'absent' : 'unknown'; const hasKiloClawInstance = kiloClawInstanceState === 'present'; - const isKiloClawPath = pathname === kiloClawBaseUrl || pathname.startsWith(kiloClawBaseUrl + '/'); + const isKiloClawPath = [kiloClawBaseUrl, kiloClawCanonicalBaseUrl].some( + basePath => pathname === basePath || pathname.startsWith(basePath + '/') + ); const [sidebarMenu, setSidebarMenu] = useState<'main' | 'kiloClaw'>( isKiloClawPath && hasKiloClawInstance ? 'kiloClaw' : 'main' ); diff --git a/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx b/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx index 0a4c6eeca4..fd834a6a13 100644 --- a/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx +++ b/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx @@ -14,6 +14,7 @@ import { cn } from '@/lib/utils'; import { Check, ChevronDown } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; +import { getOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils'; type OrganizationSwitcherProps = { organizationId?: string | null; @@ -21,6 +22,7 @@ type OrganizationSwitcherProps = { export type OrganizationSwitcherOrganization = { organizationId: string; + organizationSlug: string | null; organizationName: string; role: string; }; @@ -29,7 +31,7 @@ type OrganizationSwitcherViewProps = { organizationId?: string | null; organizations?: OrganizationSwitcherOrganization[]; isPending?: boolean; - onOrganizationSwitch: (organizationId: string | null) => void; + onOrganizationSwitch: (organization: OrganizationSwitcherOrganization | null) => void; }; const triggerClassName = @@ -58,9 +60,14 @@ export default function OrganizationSwitcher({ organizationId = null }: Organiza }) ); - const handleOrganizationSwitch = (orgId: string | null) => { - if (orgId) { - router.push(`/organizations/${orgId}`); + const handleOrganizationSwitch = (organization: OrganizationSwitcherOrganization | null) => { + if (organization) { + router.push( + `/organizations/${getOrganizationRouteIdentifier({ + id: organization.organizationId, + slug: organization.organizationSlug, + })}` + ); } else { router.push('/profile'); } @@ -142,7 +149,7 @@ export function OrganizationSwitcherView({ {organizations.map(org => ( onOrganizationSwitch(org.organizationId)} + onClick={() => onOrganizationSwitch(org)} className={cn( menuItemClassName, organizationId === org.organizationId && selectedMenuItemClassName diff --git a/apps/web/src/app/(app)/organizations/[id]/app-builder/[projectId]/page.tsx b/apps/web/src/app/(app)/organizations/[id]/app-builder/[projectId]/page.tsx index bd5e7e5e5f..0b02b200aa 100644 --- a/apps/web/src/app/(app)/organizations/[id]/app-builder/[projectId]/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/app-builder/[projectId]/page.tsx @@ -1,34 +1,30 @@ -import { notFound, redirect } from 'next/navigation'; +import { notFound } from 'next/navigation'; import { AppBuilderPage } from '@/components/app-builder/AppBuilderPage'; -import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; +import { requireCanonicalOrganizationRouteContext } from '@/lib/organizations/organization-page-context.server'; import { isFeatureFlagEnabled } from '@/lib/posthog-feature-flags'; -import { signInUrlWithCallbackPath } from '@/lib/user/server'; type Props = { params: Promise<{ id: string; projectId: string }>; }; export default async function OrgAppBuilderProjectPage({ params }: Props) { - const { id, projectId } = await params; - const organizationId = decodeURIComponent(id); + const [{ projectId }, { user, organization, canonicalRouteIdentifier }] = await Promise.all([ + params, + requireCanonicalOrganizationRouteContext(params), + ]); - const result = await getAuthorizedOrgContext(organizationId); - if (!result.success) { - if (result.nextResponse.status === 401) { - redirect(await signInUrlWithCallbackPath()); - } - redirect('/profile'); - } - - const isAppBuilderEnabled = await isFeatureFlagEnabled( - 'app-builder-feature', - result.data.user.id - ); + const isAppBuilderEnabled = await isFeatureFlagEnabled('app-builder-feature', user.id); const isDevelopment = process.env.NODE_ENV === 'development'; if (!isAppBuilderEnabled && !isDevelopment) { return notFound(); } - return ; + return ( + + ); } diff --git a/apps/web/src/app/(app)/organizations/[id]/app-builder/page.tsx b/apps/web/src/app/(app)/organizations/[id]/app-builder/page.tsx index 929215f2ea..aec1c5eddc 100644 --- a/apps/web/src/app/(app)/organizations/[id]/app-builder/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/app-builder/page.tsx @@ -1,34 +1,28 @@ -import { notFound, redirect } from 'next/navigation'; +import { notFound } from 'next/navigation'; import { AppBuilderPage } from '@/components/app-builder/AppBuilderPage'; -import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; +import { requireCanonicalOrganizationRouteContext } from '@/lib/organizations/organization-page-context.server'; import { isFeatureFlagEnabled } from '@/lib/posthog-feature-flags'; -import { signInUrlWithCallbackPath } from '@/lib/user/server'; type Props = { params: Promise<{ id: string }>; }; export default async function OrgAppBuilderPage({ params }: Props) { - const { id } = await params; - const organizationId = decodeURIComponent(id); + const { user, organization, canonicalRouteIdentifier } = + await requireCanonicalOrganizationRouteContext(params); - const result = await getAuthorizedOrgContext(organizationId); - if (!result.success) { - if (result.nextResponse.status === 401) { - redirect(await signInUrlWithCallbackPath()); - } - redirect('/profile'); - } - - const isAppBuilderEnabled = await isFeatureFlagEnabled( - 'app-builder-feature', - result.data.user.id - ); + const isAppBuilderEnabled = await isFeatureFlagEnabled('app-builder-feature', user.id); const isDevelopment = process.env.NODE_ENV === 'development'; if (!isAppBuilderEnabled && !isDevelopment) { return notFound(); } - return ; + return ( + + ); } diff --git a/apps/web/src/app/(app)/organizations/[id]/auto-fix/AutoFixPageClient.tsx b/apps/web/src/app/(app)/organizations/[id]/auto-fix/AutoFixPageClient.tsx index ca26cdd6fa..2a513b3cb8 100644 --- a/apps/web/src/app/(app)/organizations/[id]/auto-fix/AutoFixPageClient.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/auto-fix/AutoFixPageClient.tsx @@ -16,6 +16,7 @@ import Link from 'next/link'; type AutoFixPageClientProps = { organizationId: string; + organizationRouteIdentifier: string; organizationName: string; successMessage?: string; errorMessage?: string; @@ -23,6 +24,7 @@ type AutoFixPageClientProps = { export function AutoFixPageClient({ organizationId, + organizationRouteIdentifier, organizationName, successMessage, errorMessage, @@ -82,7 +84,7 @@ export function AutoFixPageClient({ The Kilo GitHub App must be installed to use Auto Fix. The app automatically manages workflows and triggers fixes on labeled issues.

- + + + + {logs.length > 0 && ( +
+

Batch log

+
+ {logs.map((log, i) => ( +
+ {log.timestamp.toLocaleTimeString()} + backfilled {log.updatedCount.toLocaleString()} organizations +
+ ))} +
+
+ )} + + ); +} diff --git a/apps/web/src/app/admin/components/OrganizationTableBody.tsx b/apps/web/src/app/admin/components/OrganizationTableBody.tsx index 93068b9f80..730a94cbe1 100644 --- a/apps/web/src/app/admin/components/OrganizationTableBody.tsx +++ b/apps/web/src/app/admin/components/OrganizationTableBody.tsx @@ -13,6 +13,10 @@ import { getStripeStatusLabel, getStripeStatusStyle, } from '@/lib/admin/stripe-subscription-statuses'; +import { + getOrganizationAppPath, + getOrganizationRouteIdentifier, +} from '@/lib/organizations/organization-route-utils'; type AdminOrganization = z.infer; @@ -77,7 +81,7 @@ function LinksCell({ organization }: { organization: AdminOrganization }) { { - router.push(`/admin/organizations/${encodeURIComponent(organizationId)}`); + const handleRowClick = (organization: AdminOrganization) => { + router.push( + `/admin/organizations/${encodeURIComponent(getOrganizationRouteIdentifier(organization))}` + ); }; if (isLoading) { @@ -338,7 +344,7 @@ export function OrganizationTableBody({ handleRowClick(organization.id)} + onClick={() => handleRowClick(organization)} > {variant === 'entitlements' ? ( ; + return ; } diff --git a/apps/web/src/app/admin/organizations/[id]/webhooks/[triggerId]/page.tsx b/apps/web/src/app/admin/organizations/[id]/webhooks/[triggerId]/page.tsx index 682275ec81..b50d8c9017 100644 --- a/apps/web/src/app/admin/organizations/[id]/webhooks/[triggerId]/page.tsx +++ b/apps/web/src/app/admin/organizations/[id]/webhooks/[triggerId]/page.tsx @@ -1,20 +1,38 @@ -'use client'; - import { Suspense } from 'react'; import { AdminWebhookTriggerDetails } from '@/app/admin/webhooks/AdminWebhookTriggerDetails'; +import { resolveOrganizationRouteIdentifierDetails } from '@/lib/organizations/organization-route-utils.server'; +import { redirect } from 'next/navigation'; type AdminOrganizationWebhookDetailPageProps = { params: Promise<{ id: string; triggerId: string }>; }; -export default function AdminOrganizationWebhookDetailPage({ +export default async function AdminOrganizationWebhookDetailPage({ params, }: AdminOrganizationWebhookDetailPageProps) { + const { id, triggerId } = await params; + const organization = await resolveOrganizationRouteIdentifierDetails(decodeURIComponent(id)); + if (!organization) { + redirect('/admin/organizations'); + } + + if (decodeURIComponent(id) !== organization.routeIdentifier) { + redirect( + `/admin/organizations/${encodeURIComponent(organization.routeIdentifier)}/webhooks/${encodeURIComponent(triggerId)}` + ); + } + + const resolvedParams = Promise.resolve({ id: organization.id, triggerId }); + return ( Loading...} > - + ); } diff --git a/apps/web/src/app/admin/organizations/[id]/webhooks/page.tsx b/apps/web/src/app/admin/organizations/[id]/webhooks/page.tsx index 795ec6fbbd..aed78c60c4 100644 --- a/apps/web/src/app/admin/organizations/[id]/webhooks/page.tsx +++ b/apps/web/src/app/admin/organizations/[id]/webhooks/page.tsx @@ -4,6 +4,8 @@ import { AdminWebhookTriggersList } from '@/app/admin/webhooks/AdminWebhookTrigg import { db } from '@/lib/drizzle'; import { organizations } from '@kilocode/db/schema'; import { eq } from 'drizzle-orm'; +import { getOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils'; +import { resolveOrganizationRouteIdentifierDetails } from '@/lib/organizations/organization-route-utils.server'; export default async function AdminOrganizationWebhooksPage({ params, @@ -16,26 +18,40 @@ export default async function AdminOrganizationWebhooksPage({ } const { id } = await params; - const organizationId = decodeURIComponent(id); + const resolvedOrganization = await resolveOrganizationRouteIdentifierDetails( + decodeURIComponent(id) + ); + if (!resolvedOrganization) { + redirect('/admin/organizations'); + } + + if (decodeURIComponent(id) !== resolvedOrganization.routeIdentifier) { + redirect( + `/admin/organizations/${encodeURIComponent(resolvedOrganization.routeIdentifier)}/webhooks` + ); + } const organization = await db.query.organizations.findFirst({ columns: { id: true, name: true, + slug: true, }, - where: eq(organizations.id, organizationId), + where: eq(organizations.id, resolvedOrganization.id), }); if (!organization) { redirect('/admin/organizations'); } + const routeIdentifier = getOrganizationRouteIdentifier(organization); + return ( ); } diff --git a/apps/web/src/app/admin/useAdminCreditManagementPermission.ts b/apps/web/src/app/admin/useAdminCreditManagementPermission.ts index e64fa0883f..ef3bfe27e6 100644 --- a/apps/web/src/app/admin/useAdminCreditManagementPermission.ts +++ b/apps/web/src/app/admin/useAdminCreditManagementPermission.ts @@ -3,10 +3,11 @@ import { useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; -export function useAdminCreditManagementPermission() { +export function useAdminCreditManagementPermission(options?: { enabled?: boolean }) { const trpc = useTRPC(); const query = useQuery( trpc.admin.getPermissions.queryOptions(undefined, { + enabled: options?.enabled ?? true, staleTime: 0, refetchOnWindowFocus: true, }) diff --git a/apps/web/src/app/admin/webhooks/AdminWebhookTriggerDetails.tsx b/apps/web/src/app/admin/webhooks/AdminWebhookTriggerDetails.tsx index 7466b4b8df..d568675ba2 100644 --- a/apps/web/src/app/admin/webhooks/AdminWebhookTriggerDetails.tsx +++ b/apps/web/src/app/admin/webhooks/AdminWebhookTriggerDetails.tsx @@ -21,23 +21,29 @@ import { WebhookRequestsContent } from '@/app/(app)/cloud/webhooks/[triggerId]/r type AdminWebhookTriggerDetailsProps = { params: Promise<{ id: string; triggerId: string }>; scope: 'user' | 'organization'; + ownerRouteIdentifier?: string; }; function formatTimestamp(value: string) { return new Date(value).toLocaleString(); } -export function AdminWebhookTriggerDetails({ params, scope }: AdminWebhookTriggerDetailsProps) { +export function AdminWebhookTriggerDetails({ + params, + scope, + ownerRouteIdentifier, +}: AdminWebhookTriggerDetailsProps) { const { id, triggerId } = use(params); const trpc = useTRPC(); const ownerId = decodeURIComponent(id); + const ownerPathIdentifier = ownerRouteIdentifier ?? ownerId; const isOrg = scope === 'organization'; const listPath = isOrg - ? `/admin/organizations/${encodeURIComponent(ownerId)}/webhooks` + ? `/admin/organizations/${encodeURIComponent(ownerPathIdentifier)}/webhooks` : `/admin/users/${encodeURIComponent(ownerId)}/webhooks`; const parentPath = isOrg - ? `/admin/organizations/${encodeURIComponent(ownerId)}` + ? `/admin/organizations/${encodeURIComponent(ownerPathIdentifier)}` : `/admin/users/${encodeURIComponent(ownerId)}`; const triggerInput = isOrg diff --git a/apps/web/src/app/api/auto-routing/mode/route.test.ts b/apps/web/src/app/api/auto-routing/mode/route.test.ts index 861c54598f..9d4f464686 100644 --- a/apps/web/src/app/api/auto-routing/mode/route.test.ts +++ b/apps/web/src/app/api/auto-routing/mode/route.test.ts @@ -22,7 +22,7 @@ const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); const mockedEnsureOrganizationAccess = jest.mocked(ensureOrganizationAccess); const USER_ID = 'user-1'; -const ORGANIZATION_ID = 'org-1'; +const ORGANIZATION_ID = '550e8400-e29b-41d4-a716-446655440000'; function makeRequest(path: string, body?: unknown) { return new NextRequest(`http://localhost:3000${path}`, { @@ -148,6 +148,21 @@ describe('/api/auto-routing/mode', () => { expect(mockedUpdateAutoRoutingMode).not.toHaveBeenCalled(); }); + test('rejects a slug passed as organizationId before organization access checks', async () => { + const response = await PUT( + makeRequest('/api/auto-routing/mode?organizationId=acme', { + mode: 'best_accuracy', + }) + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Invalid organizationId', + }); + expect(mockedEnsureOrganizationAccess).not.toHaveBeenCalled(); + expect(mockedUpdateAutoRoutingMode).not.toHaveBeenCalled(); + }); + test('maps missing organization entitlements to HTTP 404', async () => { mockedRequireActiveSubscriptionOrTrial.mockRejectedValue( new TRPCError({ diff --git a/apps/web/src/app/api/auto-routing/mode/route.ts b/apps/web/src/app/api/auto-routing/mode/route.ts index ef3baf512c..3ee2b16dea 100644 --- a/apps/web/src/app/api/auto-routing/mode/route.ts +++ b/apps/web/src/app/api/auto-routing/mode/route.ts @@ -5,6 +5,7 @@ import { } from '@kilocode/auto-routing-contracts'; import { TRPCError } from '@trpc/server'; import { NextResponse, type NextRequest } from 'next/server'; +import { z } from 'zod'; import { getAutoRoutingMode, updateAutoRoutingMode, @@ -13,6 +14,8 @@ import { getUserFromAuth } from '@/lib/user/server'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; +const OrganizationIdSchema = z.uuid(); + function workerResultResponse(result: { status: number; body: unknown }): NextResponse { if (result.status >= 400) { return NextResponse.json(result.body, { status: result.status }); @@ -47,11 +50,17 @@ async function resolveOwner( return { response: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) }; } - const organizationId = request.nextUrl.searchParams.get('organizationId'); - if (!organizationId) { + const organizationIdParam = request.nextUrl.searchParams.get('organizationId'); + if (!organizationIdParam) { return { ownerType: 'user', ownerId: user.id }; } + const parsedOrganizationId = OrganizationIdSchema.safeParse(organizationIdParam); + if (!parsedOrganizationId.success) { + return { response: NextResponse.json({ error: 'Invalid organizationId' }, { status: 400 }) }; + } + const organizationId = parsedOrganizationId.data; + try { await ensureOrganizationAccess({ user }, organizationId, roles); } catch (error) { diff --git a/apps/web/src/app/api/code-indexing/enabled/route.ts b/apps/web/src/app/api/code-indexing/enabled/route.ts index 4f53e568c7..0a69c64c1f 100644 --- a/apps/web/src/app/api/code-indexing/enabled/route.ts +++ b/apps/web/src/app/api/code-indexing/enabled/route.ts @@ -1,10 +1,13 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { z } from 'zod'; import { createTRPCContext } from '@/lib/trpc/init'; import { ensureOrganizationAccessAndFetchOrg } from '@/routers/organizations/utils'; import { getUserFromAuth } from '@/lib/user/server'; import { isEnabledForUser } from '@/lib/code-indexing/util'; +const OrganizationIdSchema = z.uuid(); + type EnabledResponse = { enabled: boolean }; type ErrorResponse = { error: string; message?: string }; @@ -36,11 +39,16 @@ export async function GET( return NextResponse.json({ enabled: isEnabledForUser(res.user) }); } + const parsedOrganizationId = OrganizationIdSchema.safeParse(organizationId); + if (!parsedOrganizationId.success) { + return NextResponse.json({ error: 'Invalid organizationId' }, { status: 400 }); + } + // Check if user has access to the organization and fetch it try { // Create tRPC context for authentication const ctx = await createTRPCContext(); - const org = await ensureOrganizationAccessAndFetchOrg(ctx, organizationId); + const org = await ensureOrganizationAccessAndFetchOrg(ctx, parsedOrganizationId.data); // Check if code indexing is enabled in organization settings const enabled = org.settings?.code_indexing_enabled === true; diff --git a/apps/web/src/app/api/code-indexing/manifest/route.ts b/apps/web/src/app/api/code-indexing/manifest/route.ts index 31e62af0d1..31f9e864ab 100644 --- a/apps/web/src/app/api/code-indexing/manifest/route.ts +++ b/apps/web/src/app/api/code-indexing/manifest/route.ts @@ -1,9 +1,12 @@ import type { NextRequest } from 'next/server'; +import { z } from 'zod'; import { handleTRPCRequest } from '@/lib/trpc-route-handler'; +const OrganizationIdSchema = z.uuid(); + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - const organizationId = searchParams.get('organizationId') || undefined; + const organizationIdParam = searchParams.get('organizationId'); const projectId = searchParams.get('projectId'); const gitBranch = searchParams.get('gitBranch'); @@ -16,6 +19,18 @@ export async function GET(request: NextRequest) { ); } + let organizationId: string | undefined; + if (organizationIdParam) { + const parsedOrganizationId = OrganizationIdSchema.safeParse(organizationIdParam); + if (!parsedOrganizationId.success) { + return new Response(JSON.stringify({ error: 'Invalid organizationId' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + organizationId = parsedOrganizationId.data; + } + return handleTRPCRequest(request, async caller => { return caller.codeIndexing.getManifest({ organizationId, diff --git a/apps/web/src/app/api/code-indexing/upsert-by-file/route.ts b/apps/web/src/app/api/code-indexing/upsert-by-file/route.ts index 1165398a0b..cdce08d44a 100644 --- a/apps/web/src/app/api/code-indexing/upsert-by-file/route.ts +++ b/apps/web/src/app/api/code-indexing/upsert-by-file/route.ts @@ -23,7 +23,7 @@ const BATCH_SIZE = 4; // Zod schema for form data validation const FormDataSchema = z.object({ file: z.instanceof(File, { message: 'file must be a File object' }), - organizationId: z.string().optional().nullable(), + organizationId: z.uuid().optional().nullable(), projectId: z.string().min(1, { message: 'projectId is required' }), filePath: z.string().min(1, { message: 'filePath is required' }), fileHash: z.string().min(1, { message: 'fileHash is required' }), diff --git a/apps/web/src/app/api/integrations/gitlab/connect/route.test.ts b/apps/web/src/app/api/integrations/gitlab/connect/route.test.ts index c96b536ddc..5681ac5142 100644 --- a/apps/web/src/app/api/integrations/gitlab/connect/route.test.ts +++ b/apps/web/src/app/api/integrations/gitlab/connect/route.test.ts @@ -29,6 +29,7 @@ const mockedCreateGitLabOAuthState = jest.mocked(createGitLabOAuthState); const mockedStoreGitLabOAuthCredentials = jest.mocked(storeGitLabOAuthCredentials); const USER_ID = '034489e8-19e0-4479-9d69-2edad719e847'; +const ORGANIZATION_ID = 'b41fc61e-827a-4bd4-a94f-16ae8ebf19e2'; function makeRequest(pathWithQuery: string) { return new NextRequest(`http://localhost:3000${pathWithQuery}`); @@ -120,7 +121,7 @@ describe('GET /api/integrations/gitlab/connect', () => { const response = await callGitLabConnect( makeRequest( - '/api/integrations/gitlab/connect?organizationId=org-gitlab-123&instanceUrl=https%3A%2F%2Fgitlab.example.com&clientId=client-id&clientSecret=client-secret' + `/api/integrations/gitlab/connect?organizationId=${ORGANIZATION_ID}&instanceUrl=https%3A%2F%2Fgitlab.example.com&clientId=client-id&clientSecret=client-secret` ) ); @@ -129,7 +130,7 @@ describe('GET /api/integrations/gitlab/connect', () => { const url = new URL(location ?? ''); expect(url.pathname).toBe('/users/sign_in'); expect(url.searchParams.get('callbackPath')).toBe( - '/organizations/org-gitlab-123/integrations/gitlab' + `/organizations/${ORGANIZATION_ID}/integrations/gitlab` ); expect(location).not.toContain('clientSecret'); expect(location).not.toContain('client-secret'); @@ -138,6 +139,31 @@ describe('GET /api/integrations/gitlab/connect', () => { expect(mockedBuildGitLabOAuthUrl).not.toHaveBeenCalled(); }); + test('does not preserve malformed organization IDs in self-hosted sign-in callbacks', async () => { + mockedGetUserFromAuth.mockResolvedValue({ + user: null, + authFailedResponse: new Response(null, { status: 401 }), + } as never); + + const response = await callGitLabConnect( + makeRequest( + '/api/integrations/gitlab/connect?organizationId=not-a-uuid&instanceUrl=https%3A%2F%2Fgitlab.example.com&clientId=client-id&clientSecret=client-secret' + ) + ); + + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + const url = new URL(location ?? ''); + expect(url.pathname).toBe('/users/sign_in'); + expect(url.searchParams.get('callbackPath')).toBe('/integrations/gitlab'); + expect(location).not.toContain('clientSecret'); + expect(location).not.toContain('client-secret'); + expect(location).not.toContain('not-a-uuid'); + expect(mockedCreateGitLabOAuthState).not.toHaveBeenCalled(); + expect(mockedStoreGitLabOAuthCredentials).not.toHaveBeenCalled(); + expect(mockedBuildGitLabOAuthUrl).not.toHaveBeenCalled(); + }); + test('does not initialize a self-hosted flow without custom OAuth credentials', async () => { const response = await callGitLabConnect( makeRequest('/api/integrations/gitlab/connect?instanceUrl=https%3A%2F%2Fattacker.example') diff --git a/apps/web/src/app/api/integrations/linear/connect/route.test.ts b/apps/web/src/app/api/integrations/linear/connect/route.test.ts index 0ea2c4f6a0..61dd30ba92 100644 --- a/apps/web/src/app/api/integrations/linear/connect/route.test.ts +++ b/apps/web/src/app/api/integrations/linear/connect/route.test.ts @@ -25,6 +25,7 @@ const mockedGetLinearOAuthUrl = jest.mocked(getLinearOAuthUrl); const mockedEnsureOrganizationAccess = jest.mocked(ensureOrganizationAccess); const mockedRequireActiveSubscriptionOrTrial = jest.mocked(requireActiveSubscriptionOrTrial); const USER_ID = '034489e8-19e0-4479-9d69-2edad719e847'; +const ORGANIZATION_ID = '7e3011af-e99d-444f-8171-54c2225b87dc'; function makeRequest(pathWithQuery: string) { return new NextRequest(`http://localhost:3000${pathWithQuery}`); @@ -57,7 +58,7 @@ describe('GET /api/integrations/linear/connect', () => { } as never); const response = await callLinearConnect( - makeRequest('/api/integrations/linear/connect?organizationId=org-linear-123') + makeRequest(`/api/integrations/linear/connect?organizationId=${ORGANIZATION_ID}`) ); const location = response.headers.get('location'); @@ -65,7 +66,7 @@ describe('GET /api/integrations/linear/connect', () => { const url = new URL(location ?? ''); expect(url.pathname).toBe('/users/sign_in'); expect(url.searchParams.get('callbackPath')).toBe( - '/api/integrations/linear/connect?organizationId=org-linear-123' + `/api/integrations/linear/connect?organizationId=${ORGANIZATION_ID}` ); expect(mockedGetLinearOAuthUrl).not.toHaveBeenCalled(); }); @@ -87,19 +88,19 @@ describe('GET /api/integrations/linear/connect', () => { test('authorizes org-scoped installs for owners and billing managers with an active subscription', async () => { await callLinearConnect( - makeRequest('/api/integrations/linear/connect?organizationId=org-linear-123') + makeRequest(`/api/integrations/linear/connect?organizationId=${ORGANIZATION_ID}`) ); expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( { user: expect.objectContaining({ id: USER_ID }) }, - 'org-linear-123', + ORGANIZATION_ID, ['owner', 'billing_manager'] ); - expect(mockedRequireActiveSubscriptionOrTrial).toHaveBeenCalledWith('org-linear-123'); + expect(mockedRequireActiveSubscriptionOrTrial).toHaveBeenCalledWith(ORGANIZATION_ID); const state = mockedGetLinearOAuthUrl.mock.calls[0]?.[0]; expect(verifyOAuthState(state ?? null)).toEqual( expect.objectContaining({ - owner: 'org_org-linear-123', + owner: `org_${ORGANIZATION_ID}`, userId: USER_ID, }) ); @@ -109,31 +110,45 @@ describe('GET /api/integrations/linear/connect', () => { mockedEnsureOrganizationAccess.mockRejectedValue(new Error('unauthorized')); const response = await callLinearConnect( - makeRequest('/api/integrations/linear/connect?organizationId=org-linear-123') + makeRequest(`/api/integrations/linear/connect?organizationId=${ORGANIZATION_ID}`) ); const location = response.headers.get('location'); expect(location).toBeTruthy(); const url = new URL(location ?? ''); expect(`${url.pathname}${url.search}`).toBe( - '/organizations/org-linear-123/integrations/linear?error=oauth_init_failed' + `/organizations/${ORGANIZATION_ID}/integrations/linear?error=oauth_init_failed` ); expect(mockedRequireActiveSubscriptionOrTrial).not.toHaveBeenCalled(); expect(mockedGetLinearOAuthUrl).not.toHaveBeenCalled(); }); + test('redirects malformed organization IDs to the generic integration error path', async () => { + const response = await callLinearConnect( + makeRequest('/api/integrations/linear/connect?organizationId=not-a-uuid') + ); + + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + const url = new URL(location ?? ''); + expect(`${url.pathname}${url.search}`).toBe('/integrations/linear?error=oauth_init_failed'); + expect(mockedEnsureOrganizationAccess).not.toHaveBeenCalled(); + expect(mockedRequireActiveSubscriptionOrTrial).not.toHaveBeenCalled(); + expect(mockedGetLinearOAuthUrl).not.toHaveBeenCalled(); + }); + test('redirects inactive org subscription failures without creating an OAuth URL', async () => { mockedRequireActiveSubscriptionOrTrial.mockRejectedValue(new Error('inactive subscription')); const response = await callLinearConnect( - makeRequest('/api/integrations/linear/connect?organizationId=org-linear-123') + makeRequest(`/api/integrations/linear/connect?organizationId=${ORGANIZATION_ID}`) ); const location = response.headers.get('location'); expect(location).toBeTruthy(); const url = new URL(location ?? ''); expect(`${url.pathname}${url.search}`).toBe( - '/organizations/org-linear-123/integrations/linear?error=oauth_init_failed' + `/organizations/${ORGANIZATION_ID}/integrations/linear?error=oauth_init_failed` ); expect(mockedGetLinearOAuthUrl).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/app/api/internal/auto-routing-benchmark/token/route.test.ts b/apps/web/src/app/api/internal/auto-routing-benchmark/token/route.test.ts index 11ff95ea77..e0f6700201 100644 --- a/apps/web/src/app/api/internal/auto-routing-benchmark/token/route.test.ts +++ b/apps/web/src/app/api/internal/auto-routing-benchmark/token/route.test.ts @@ -30,6 +30,7 @@ jest.mock('@/lib/tokens', () => ({ import { POST } from './route'; const mockGenerateApiToken = jest.mocked(generateApiToken); +const ORGANIZATION_ID = '550e8400-e29b-41d4-a716-446655440000'; function createRequest(body: unknown, headers: Record = {}) { return new NextRequest('http://localhost:3000/api/internal/auto-routing-benchmark/token', { @@ -98,7 +99,7 @@ describe('POST /api/internal/auto-routing-benchmark/token', () => { const res = await POST( createRequest( - { userId: 'user-1', organizationId: 'org-1' }, + { userId: 'user-1', organizationId: ORGANIZATION_ID }, { authorization: 'Bearer internal-secret' } ) ); @@ -106,7 +107,11 @@ describe('POST /api/internal/auto-routing-benchmark/token', () => { expect(res.status).toBe(200); expect(mockGenerateApiToken).toHaveBeenCalledWith( user, - { tokenSource: 'auto-routing-benchmark', organizationId: 'org-1', organizationRole: 'owner' }, + { + tokenSource: 'auto-routing-benchmark', + organizationId: ORGANIZATION_ID, + organizationRole: 'owner', + }, { expiresIn: 6 * 60 * 60 } ); }); diff --git a/apps/web/src/app/api/internal/auto-routing-benchmark/token/route.ts b/apps/web/src/app/api/internal/auto-routing-benchmark/token/route.ts index 71c7f94d37..367a99c164 100644 --- a/apps/web/src/app/api/internal/auto-routing-benchmark/token/route.ts +++ b/apps/web/src/app/api/internal/auto-routing-benchmark/token/route.ts @@ -33,7 +33,7 @@ import { INTERNAL_API_SECRET } from '@/lib/config.server'; const RequestSchema = z.object({ userId: z.string().min(1), - organizationId: z.string().min(1).optional(), + organizationId: z.uuid().optional(), }); const SIX_HOURS_IN_SECONDS = 6 * 60 * 60; diff --git a/apps/web/src/app/api/internal/integrations/dolthub/token/route.ts b/apps/web/src/app/api/internal/integrations/dolthub/token/route.ts index 273040d02d..fac56c7806 100644 --- a/apps/web/src/app/api/internal/integrations/dolthub/token/route.ts +++ b/apps/web/src/app/api/internal/integrations/dolthub/token/route.ts @@ -26,7 +26,7 @@ import { INTEGRATION_STATUS } from '@/lib/integrations/core/constants'; const RequestSchema = z .object({ userId: z.string().min(1).optional(), - organizationId: z.string().min(1).optional(), + organizationId: z.uuid().optional(), }) .refine(v => Boolean(v.userId) !== Boolean(v.organizationId), { message: 'Provide exactly one of userId or organizationId', diff --git a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts index d02753cfad..178b12b96a 100644 --- a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts +++ b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.test.ts @@ -62,6 +62,7 @@ const mockProcessPersonalKiloClawPaidConversion = jest.mocked( processPersonalKiloClawPaidConversion ); const mockEnforceKiloClawCommitRetirementGuard = jest.mocked(enforceKiloClawCommitRetirementGuard); +const ORGANIZATION_ID = '550e8400-e29b-41d4-a716-446655440000'; type ConsoleSpy = jest.SpiedFunction | jest.SpiedFunction; @@ -174,11 +175,11 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { organization_name: 'Acme Corp', instance_label: 'Research Claw', destruction_date: 'May 25, 2026', - organization_billing_url: 'https://app.kilo.ai/organizations/org-123/payment-details', + organization_billing_url: `https://app.kilo.ai/organizations/${ORGANIZATION_ID}/payment-details`, }, userId: 'owner-123', instanceId: 'instance-456', - organizationId: 'org-123', + organizationId: ORGANIZATION_ID, }, }) ); @@ -189,14 +190,14 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { templateName: 'clawOrganizationTrialSuspendedBillingAuthority', userId: 'owner-123', instanceId: 'instance-456', - organizationId: 'org-123', + organizationId: ORGANIZATION_ID, }) ); expect(findJsonLog(consoleLogSpy, 'Starting billing side effect request')).toEqual( expect.objectContaining({ userId: 'owner-123', instanceId: 'instance-456', - organizationId: 'org-123', + organizationId: ORGANIZATION_ID, templateName: 'clawOrganizationTrialSuspendedBillingAuthority', }) ); @@ -218,7 +219,7 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { }, userId: 'member-123', instanceId: 'instance-456', - organizationId: 'org-123', + organizationId: ORGANIZATION_ID, }, }) ); @@ -238,9 +239,9 @@ describe('POST /api/internal/kiloclaw/billing-side-effects', () => { templateVars: { organization_name: 'Acme Corp', instance_label: 'Research Claw', - organization_billing_url: 'https://app.kilo.ai/organizations/org-123/payment-details', + organization_billing_url: `https://app.kilo.ai/organizations/${ORGANIZATION_ID}/payment-details`, }, - organizationId: 'org-123', + organizationId: ORGANIZATION_ID, }, }) ); diff --git a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts index c4bdc58012..f0443da0a5 100644 --- a/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts +++ b/apps/web/src/app/api/internal/kiloclaw/billing-side-effects/route.ts @@ -114,7 +114,7 @@ const OrganizationLifecycleIdentitySchema = { to: z.email(), userId: z.string().min(1), instanceId: z.string().min(1), - organizationId: z.string().min(1), + organizationId: z.uuid(), }; const OrganizationLifecycleBillingAuthorityVarsSchema = z.object({ diff --git a/apps/web/src/app/api/internal/security-agent/notifications/route.ts b/apps/web/src/app/api/internal/security-agent/notifications/route.ts index a9e4ab070d..f33753a904 100644 --- a/apps/web/src/app/api/internal/security-agent/notifications/route.ts +++ b/apps/web/src/app/api/internal/security-agent/notifications/route.ts @@ -14,6 +14,7 @@ import { db } from '@/lib/drizzle'; import { INTERNAL_API_SECRET, NEXTAUTH_URL } from '@/lib/config.server'; import { send as sendEmail, type TemplateName } from '@/lib/email'; import { securityFindingTemplateVars } from '@/lib/security-notification-email-vars'; +import { getOrganizationAppPathById } from '@/lib/organizations/organization-route-utils.server'; import { SecurityNotificationPolicySchema, getEligibleSlaNotificationKind, @@ -54,31 +55,36 @@ function formatDeadline(iso: string | null): string { ); } -function securityAgentUrl( +async function securityAgentUrl( finding: { ownedByOrganizationId: string | null; ownedByUserId: string | null; }, path: string -): string { +): Promise { if (finding.ownedByOrganizationId) { - return `${NEXTAUTH_URL}/organizations/${finding.ownedByOrganizationId}/security-agent/${path}`; + const organizationPath = + (await getOrganizationAppPathById( + finding.ownedByOrganizationId, + `/security-agent/${path}` + )) ?? `/organizations/${finding.ownedByOrganizationId}/security-agent/${path}`; + return `${NEXTAUTH_URL}${organizationPath}`; } return `${NEXTAUTH_URL}/security-agent/${path}`; } -function actionUrl(finding: { +async function actionUrl(finding: { ownedByOrganizationId: string | null; ownedByUserId: string | null; -}): string { +}): Promise { return securityAgentUrl(finding, 'findings'); } -function manageNotificationsUrl(finding: { +async function manageNotificationsUrl(finding: { kind: 'new_finding' | 'sla_warning' | 'sla_breach'; ownedByOrganizationId: string | null; ownedByUserId: string | null; -}): string { +}): Promise { const tab = finding.kind === 'new_finding' ? 'notifications' : 'sla'; return securityAgentUrl(finding, `config?tab=${tab}`); } @@ -258,6 +264,10 @@ export async function POST(req: NextRequest) { } const templateName = notificationKindToTemplate[row.kind]; + const [findingActionUrl, findingManageNotificationsUrl] = await Promise.all([ + actionUrl(row), + manageNotificationsUrl(row), + ]); const result = await sendEmail({ to: row.recipientEmail, templateName, @@ -270,8 +280,8 @@ export async function POST(req: NextRequest) { ghsaId: row.ghsaId, cvssScore: row.cvssScore, slaDeadline: formatDeadline(row.slaDueAt), - actionUrl: actionUrl(row), - manageNotificationsUrl: manageNotificationsUrl(row), + actionUrl: findingActionUrl, + manageNotificationsUrl: findingManageNotificationsUrl, }), }).catch(() => null); diff --git a/apps/web/src/app/api/organizations/[id]/models/route.test.ts b/apps/web/src/app/api/organizations/[id]/models/route.test.ts new file mode 100644 index 0000000000..259e052ef2 --- /dev/null +++ b/apps/web/src/app/api/organizations/[id]/models/route.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, test } from '@jest/globals'; +import { NextRequest, NextResponse } from 'next/server'; +import type { OpenRouterModel } from '@/lib/organizations/organization-types'; +import { handleTRPCRequest } from '@/lib/trpc-route-handler'; +import { resolveOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils.server'; +import { GET } from './route'; + +jest.mock('@/lib/trpc-route-handler', () => ({ handleTRPCRequest: jest.fn() })); +jest.mock('@/lib/organizations/organization-route-utils.server', () => ({ + resolveOrganizationRouteIdentifier: jest.fn(), +})); + +const mockedHandleTRPCRequest = jest.mocked(handleTRPCRequest); +const mockedResolveOrganizationRouteIdentifier = jest.mocked(resolveOrganizationRouteIdentifier); +const listAvailableModels = jest.fn(); + +function makeModel(id: string): OpenRouterModel { + return { + id, + name: id, + created: 0, + description: '', + architecture: { + input_modalities: ['text'], + output_modalities: ['text'], + tokenizer: 'test', + }, + top_provider: { is_moderated: false }, + pricing: { prompt: '0', completion: '0' }, + context_length: 0, + supported_parameters: ['tools'], + }; +} + +describe('GET /api/organizations/[id]/models', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedResolveOrganizationRouteIdentifier.mockResolvedValue( + '550e8400-e29b-41d4-a716-446655440000' + ); + listAvailableModels.mockResolvedValue({ data: [makeModel('available/model')] }); + mockedHandleTRPCRequest.mockImplementation(async (_request, handler) => { + const result = await handler({ + organizations: { settings: { listAvailableModels } }, + } as never); + return NextResponse.json(result); + }); + }); + + test('resolves the organization route identifier after authentication', async () => { + const response = await GET( + new NextRequest('http://localhost:3000/api/organizations/acme/models'), + { params: Promise.resolve({ id: 'acme' }) } + ); + + expect(response.status).toBe(200); + expect(mockedResolveOrganizationRouteIdentifier).toHaveBeenCalledWith('acme'); + expect(listAvailableModels).toHaveBeenCalledWith({ + organizationId: '550e8400-e29b-41d4-a716-446655440000', + }); + }); + + test('does not resolve organization slugs when authentication fails', async () => { + mockedHandleTRPCRequest.mockResolvedValue( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ); + + const response = await GET( + new NextRequest('http://localhost:3000/api/organizations/acme/models'), + { params: Promise.resolve({ id: 'acme' }) } + ); + + expect(response.status).toBe(401); + expect(mockedResolveOrganizationRouteIdentifier).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/organizations/[id]/models/route.ts b/apps/web/src/app/api/organizations/[id]/models/route.ts index 1a47cf6ba5..3687f768a2 100644 --- a/apps/web/src/app/api/organizations/[id]/models/route.ts +++ b/apps/web/src/app/api/organizations/[id]/models/route.ts @@ -3,12 +3,19 @@ import type { OpenRouterModelsResponse } from '@/lib/organizations/organization- import { handleTRPCRequest } from '@/lib/trpc-route-handler'; import { FEATURE_HEADER, validateFeatureHeader } from '@/lib/feature-detection'; import { filterByFeature } from '@/lib/ai-gateway/models'; +import { resolveOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils.server'; +import { TRPCError } from '@trpc/server'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const organizationId = (await params).id; + const routeIdentifier = (await params).id; const feature = validateFeatureHeader(request.headers.get(FEATURE_HEADER)); return handleTRPCRequest(request, async caller => { + const organizationId = await resolveOrganizationRouteIdentifier(routeIdentifier); + if (!organizationId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Organization not found' }); + } + const result = await caller.organizations.settings.listAvailableModels({ organizationId }); return { ...result, data: filterByFeature(result.data, feature) }; }); diff --git a/apps/web/src/app/api/organizations/[id]/models/validate/route.test.ts b/apps/web/src/app/api/organizations/[id]/models/validate/route.test.ts index 209e8747c9..4d59c458a2 100644 --- a/apps/web/src/app/api/organizations/[id]/models/validate/route.test.ts +++ b/apps/web/src/app/api/organizations/[id]/models/validate/route.test.ts @@ -2,12 +2,19 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; import { NextRequest, NextResponse } from 'next/server'; import type { OpenRouterModel } from '@/lib/organizations/organization-types'; import { handleTRPCRequest } from '@/lib/trpc-route-handler'; +import { resolveOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils.server'; import { POST } from './route'; +import { TRPCError } from '@trpc/server'; jest.mock('@/lib/trpc-route-handler', () => ({ handleTRPCRequest: jest.fn() })); +jest.mock('@/lib/organizations/organization-route-utils.server', () => ({ + resolveOrganizationRouteIdentifier: jest.fn(), +})); const mockedHandleTRPCRequest = jest.mocked(handleTRPCRequest); +const mockedResolveOrganizationRouteIdentifier = jest.mocked(resolveOrganizationRouteIdentifier); const listAvailableModels = jest.fn(); +const ORGANIZATION_ID = '550e8400-e29b-41d4-a716-446655440000'; function makeModel(id: string): OpenRouterModel { return { @@ -28,7 +35,7 @@ function makeModel(id: string): OpenRouterModel { } function request(modelId: string) { - return new NextRequest('http://localhost:3000/api/organizations/org-1/models/validate', { + return new NextRequest('http://localhost:3000/api/organizations/acme/models/validate', { method: 'POST', body: JSON.stringify({ modelId }), }); @@ -37,42 +44,83 @@ function request(modelId: string) { describe('POST /api/organizations/[id]/models/validate', () => { beforeEach(() => { jest.resetAllMocks(); + mockedResolveOrganizationRouteIdentifier.mockResolvedValue(ORGANIZATION_ID); listAvailableModels.mockResolvedValue({ data: [makeModel('available/model')] }); - mockedHandleTRPCRequest.mockImplementation(async (request, handler) => { - const result = await handler({ - organizations: { settings: { listAvailableModels } }, - } as never); - return NextResponse.json(result); + mockedHandleTRPCRequest.mockImplementation(async (_request, handler) => { + try { + const result = await handler({ + organizations: { settings: { listAvailableModels } }, + } as never); + return NextResponse.json(result); + } catch (error) { + if (error instanceof TRPCError) { + return NextResponse.json( + { error: error.message, message: error.message }, + { status: 404 } + ); + } + throw error; + } }); }); test('validates against the authorized organization catalog', async () => { const response = await POST(request('available/model'), { - params: Promise.resolve({ id: 'org-1' }), + params: Promise.resolve({ id: 'acme' }), }); - expect(listAvailableModels).toHaveBeenCalledWith({ organizationId: 'org-1' }); + expect(mockedResolveOrganizationRouteIdentifier).toHaveBeenCalledWith('acme'); + expect(listAvailableModels).toHaveBeenCalledWith({ organizationId: ORGANIZATION_ID }); await expect(response.json()).resolves.toEqual({ valid: true }); }); test('reports an organization-unavailable model without policy details', async () => { const response = await POST(request('missing/model'), { - params: Promise.resolve({ id: 'org-1' }), + params: Promise.resolve({ id: 'acme' }), }); await expect(response.json()).resolves.toEqual({ valid: false, reason: 'unavailable' }); }); + test('returns 404 when the route identifier cannot be resolved', async () => { + mockedResolveOrganizationRouteIdentifier.mockResolvedValue(null); + + const response = await POST(request('available/model'), { + params: Promise.resolve({ id: 'missing-org' }), + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: 'Organization not found', + message: 'Organization not found', + }); + expect(mockedHandleTRPCRequest).toHaveBeenCalled(); + }); + + test('does not resolve organization slugs when authentication fails', async () => { + mockedHandleTRPCRequest.mockResolvedValue( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ); + + const response = await POST(request('available/model'), { + params: Promise.resolve({ id: 'acme' }), + }); + + expect(response.status).toBe(401); + expect(mockedResolveOrganizationRouteIdentifier).not.toHaveBeenCalled(); + }); + test('rejects an invalid body before invoking organization authorization', async () => { const response = await POST( - new NextRequest('http://localhost:3000/api/organizations/org-1/models/validate', { + new NextRequest('http://localhost:3000/api/organizations/acme/models/validate', { method: 'POST', body: JSON.stringify({ modelId: '' }), }), - { params: Promise.resolve({ id: 'org-1' }) } + { params: Promise.resolve({ id: 'acme' }) } ); expect(response.status).toBe(400); + expect(mockedResolveOrganizationRouteIdentifier).not.toHaveBeenCalled(); expect(mockedHandleTRPCRequest).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/api/organizations/[id]/models/validate/route.ts b/apps/web/src/app/api/organizations/[id]/models/validate/route.ts index cb4dc1e42e..2eeafae53a 100644 --- a/apps/web/src/app/api/organizations/[id]/models/validate/route.ts +++ b/apps/web/src/app/api/organizations/[id]/models/validate/route.ts @@ -4,6 +4,8 @@ import * as z from 'zod'; import { handleTRPCRequest } from '@/lib/trpc-route-handler'; import { FEATURE_HEADER, validateFeatureHeader } from '@/lib/feature-detection'; import { filterByFeature } from '@/lib/ai-gateway/models'; +import { resolveOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils.server'; +import { TRPCError } from '@trpc/server'; const BodySchema = z.object({ modelId: z.string().trim().min(1) }); @@ -28,10 +30,15 @@ export async function POST( ); } - const organizationId = (await params).id; + const routeIdentifier = (await params).id; const feature = validateFeatureHeader(request.headers.get(FEATURE_HEADER)); return handleTRPCRequest(request, async caller => { + const organizationId = await resolveOrganizationRouteIdentifier(routeIdentifier); + if (!organizationId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Organization not found' }); + } + const result = await caller.organizations.settings.listAvailableModels({ organizationId }); const available = filterByFeature(result.data, feature).some( model => model.id === bodyResult.data.modelId diff --git a/apps/web/src/app/api/organizations/[id]/route.test.ts b/apps/web/src/app/api/organizations/[id]/route.test.ts new file mode 100644 index 0000000000..6fc34fe66c --- /dev/null +++ b/apps/web/src/app/api/organizations/[id]/route.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, test } from '@jest/globals'; +import { NextRequest, NextResponse } from 'next/server'; +import { TRPCError } from '@trpc/server'; +import { handleTRPCRequest } from '@/lib/trpc-route-handler'; +import { resolveOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils.server'; +import { GET } from './route'; + +jest.mock('@/lib/trpc-route-handler', () => ({ handleTRPCRequest: jest.fn() })); +jest.mock('@/lib/organizations/organization-route-utils.server', () => ({ + resolveOrganizationRouteIdentifier: jest.fn(), +})); + +const mockedHandleTRPCRequest = jest.mocked(handleTRPCRequest); +const mockedResolveOrganizationRouteIdentifier = jest.mocked(resolveOrganizationRouteIdentifier); + +describe('GET /api/organizations/[id]', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockedResolveOrganizationRouteIdentifier.mockResolvedValue( + '550e8400-e29b-41d4-a716-446655440000' + ); + mockedHandleTRPCRequest.mockImplementation(async (_request, handler) => { + try { + const result = await handler({ + organizations: { + withMembers: jest.fn().mockResolvedValue({ + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'Acme', + settings: {}, + }), + }, + } as never); + return NextResponse.json(result); + } catch (error) { + if (error instanceof TRPCError) { + return NextResponse.json( + { error: error.message, message: error.message }, + { status: 404 } + ); + } + throw error; + } + }); + }); + + test('resolves the organization route identifier after authentication', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/organizations/acme'), { + params: Promise.resolve({ id: 'acme' }), + }); + + expect(response.status).toBe(200); + expect(mockedResolveOrganizationRouteIdentifier).toHaveBeenCalledWith('acme'); + }); + + test('does not resolve organization slugs when authentication fails', async () => { + mockedHandleTRPCRequest.mockResolvedValue( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ); + + const response = await GET(new NextRequest('http://localhost:3000/api/organizations/acme'), { + params: Promise.resolve({ id: 'acme' }), + }); + + expect(response.status).toBe(401); + expect(mockedResolveOrganizationRouteIdentifier).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/organizations/[id]/route.ts b/apps/web/src/app/api/organizations/[id]/route.ts index 1d66fa0e90..8eccddf63b 100644 --- a/apps/web/src/app/api/organizations/[id]/route.ts +++ b/apps/web/src/app/api/organizations/[id]/route.ts @@ -1,10 +1,17 @@ import type { NextRequest } from 'next/server'; import { handleTRPCRequest } from '@/lib/trpc-route-handler'; +import { resolveOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils.server'; +import { TRPCError } from '@trpc/server'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const organizationId = (await params).id; + const routeIdentifier = (await params).id; return handleTRPCRequest(request, async caller => { + const organizationId = await resolveOrganizationRouteIdentifier(routeIdentifier); + if (!organizationId) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Organization not found' }); + } + const org = await caller.organizations.withMembers({ organizationId }); const res = { id: org.id, diff --git a/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts b/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts index a64d1a617f..d4bc931ac5 100644 --- a/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts +++ b/apps/web/src/app/api/organizations/[id]/user-tokens/route.ts @@ -5,16 +5,17 @@ import { generateOrganizationApiToken } from '@/lib/tokens'; import { createAuditLog } from '@/lib/organizations/organization-audit-logs'; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const organizationId = (await params).id; + const routeIdentifier = (await params).id; // Verify user has access to the organization (any member role is sufficient) - const result = await getAuthorizedOrgContext(organizationId); + const result = await getAuthorizedOrgContext(routeIdentifier); if (!result.success) { return result.nextResponse; } const { user, organization } = result.data; + const organizationId = organization.id; // Generate the organization-scoped JWT token (15 minute expiration) const { token, expiresAt } = generateOrganizationApiToken(user, organizationId, user.role); diff --git a/apps/web/src/app/api/organizations/hooks.ts b/apps/web/src/app/api/organizations/hooks.ts index e74f42cd7c..69639f0f1a 100644 --- a/apps/web/src/app/api/organizations/hooks.ts +++ b/apps/web/src/app/api/organizations/hooks.ts @@ -135,6 +135,17 @@ export function useOrganizationUsageStats(organizationId: string) { return useQuery(trpc.organizations.usageStats.queryOptions({ organizationId })); } +export function useSlugAvailability( + organizationId: string, + slug: string, + options?: { enabled?: boolean } +) { + const trpc = useTRPC(); + return useQuery( + trpc.organizations.slugAvailability.queryOptions({ organizationId, slug }, options) + ); +} + export function useOrganizationAutocompleteMetrics( organizationId: string, period: TimePeriod = 'month' @@ -219,6 +230,20 @@ export function useUpdateOrganizationName() { ); } +export function useUpdateOrganizationSlug() { + const trpc = useTRPC(); + const invalidate = useInvalidateAllOrganizationData(); + const queryClient = useQueryClient(); + return useMutation( + trpc.organizations.updateSlug.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['admin-organizations'] }); + void invalidate(); + }, + }) + ); +} + export function useUpdateCompanyDomain() { const trpc = useTRPC(); const onSuccess = useInvalidateOrganizationAndMembers(); diff --git a/apps/web/src/app/api/profile/usage/route.ts b/apps/web/src/app/api/profile/usage/route.ts index 748ae18c0c..d9808d9e3e 100644 --- a/apps/web/src/app/api/profile/usage/route.ts +++ b/apps/web/src/app/api/profile/usage/route.ts @@ -6,8 +6,11 @@ import { timedUsageQuery } from '@/lib/usage-query'; import { microdollar_usage } from '@kilocode/db/schema'; import { eq, sql, desc, isNull, and, gte } from 'drizzle-orm'; import { getDateThreshold, type Period } from '@/routers/user-router'; +import * as z from 'zod'; +import { isOrganizationMember } from '@/lib/organizations/organizations'; const VALID_PERIODS = new Set(['week', 'month', 'year', 'all']); +const ViewTypeSchema = z.union([z.literal('personal'), z.literal('all'), z.uuid()]); export async function GET(request: NextRequest) { const { user, authFailedResponse } = await getUserFromAuth({ @@ -18,12 +21,23 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const groupByModel = searchParams.get('groupByModel') === 'true'; - const viewType = searchParams.get('viewType') || 'personal'; // 'personal', 'all', or organization ID + const viewTypeResult = ViewTypeSchema.safeParse(searchParams.get('viewType') || 'personal'); + if (!viewTypeResult.success) { + return NextResponse.json({ error: 'Invalid viewType' }, { status: 400 }); + } + const viewType = viewTypeResult.data; const periodParam = searchParams.get('period') || 'week'; const period: Period = VALID_PERIODS.has(periodParam) ? (periodParam as Period) : 'week'; const userId = user.id; + if (viewType !== 'personal' && viewType !== 'all') { + const hasAccess = await isOrganizationMember(viewType, userId); + if (!hasAccess) { + return NextResponse.json({ error: 'Organization not found' }, { status: 404 }); + } + } + // Build the select object conditionally const selectFields = { date: sql`DATE(${microdollar_usage.created_at})`, diff --git a/apps/web/src/app/payments/subscriptions/success/page.tsx b/apps/web/src/app/payments/subscriptions/success/page.tsx index fb6d0f7efc..7c3cfd341d 100644 --- a/apps/web/src/app/payments/subscriptions/success/page.tsx +++ b/apps/web/src/app/payments/subscriptions/success/page.tsx @@ -1,5 +1,6 @@ import { StripeSessionStatusChecker } from '@/components/payment/StripeSessionStatusChecker'; import { STRIPE_SUB_QUERY_STRING_KEY } from '@/lib/organizations/constants'; +import { getOrganizationAppPathById } from '@/lib/organizations/organization-route-utils.server'; import { getUserFromAuthOrRedirect } from '@/lib/user/server'; import assert from 'assert'; @@ -14,5 +15,7 @@ export default async function Page({ const organizationId = params['organizationId']; assert(sessionId && typeof sessionId === 'string'); assert(organizationId && typeof organizationId === 'string'); - return ; + const organizationPath = await getOrganizationAppPathById(organizationId); + assert(organizationPath); + return ; } diff --git a/apps/web/src/app/payments/topup/route.ts b/apps/web/src/app/payments/topup/route.ts index 6540150b70..c9431253ec 100644 --- a/apps/web/src/app/payments/topup/route.ts +++ b/apps/web/src/app/payments/topup/route.ts @@ -7,6 +7,8 @@ import { isValidReturnUrl } from '@/lib/payment-return-url'; import { captureException } from '@sentry/nextjs'; import { getOrCreateStripeCustomerIdForOrganization } from '@/lib/organizations/organization-billing'; import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; +import { getOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils'; +import { TOPUP_CANCELED_QUERY_STRING_KEY } from '@/lib/organizations/constants'; /** * NOTE: Crypto payment support (Coinbase Commerce) was removed in January 2026. @@ -58,25 +60,36 @@ export async function POST(request: NextRequest): Promise> return NextResponse.json({ error: validationResult.error }, { status: 400 }); } - // validate org id - const organizationId = searchParams.get('organization-id'); - if (organizationId && typeof organizationId !== 'string') { + // Accept a route identifier at the URL boundary, then immediately normalize to + // the database UUID before calling billing or writing Stripe metadata. + const organizationRouteIdentifier = searchParams.get('organization-id'); + if (organizationRouteIdentifier && typeof organizationRouteIdentifier !== 'string') { return NextResponse.json({ error: 'Invalid org id' }, { status: 400 }); } let stripeCustomerId: string | null | undefined; - if (organizationId) { - const orgContext = await getAuthorizedOrgContext(organizationId, ['owner', 'billing_manager']); + let organizationId: string | null = null; + let organizationCancelPath: string | null = null; + if (organizationRouteIdentifier) { + const orgContext = await getAuthorizedOrgContext(organizationRouteIdentifier, [ + 'owner', + 'billing_manager', + ]); if (!orgContext.success) { return orgContext.nextResponse; } + organizationId = orgContext.data.organization.id; + const canonicalRouteIdentifier = getOrganizationRouteIdentifier(orgContext.data.organization); + organizationCancelPath = `/organizations/${canonicalRouteIdentifier}?${TOPUP_CANCELED_QUERY_STRING_KEY}=true`; stripeCustomerId = await getOrCreateStripeCustomerIdForOrganization(organizationId); } else { stripeCustomerId = currentUser.stripe_customer_id; } const cancelPathRaw = searchParams.get('cancel-path'); - const cancelPath = cancelPathRaw && isValidReturnUrl(cancelPathRaw) ? cancelPathRaw : null; + const cancelPath = + (cancelPathRaw && isValidReturnUrl(cancelPathRaw) ? cancelPathRaw : null) ?? + organizationCancelPath; const url = await getStripeTopUpCheckoutUrl( currentUser.id, diff --git a/apps/web/src/app/payments/topup/success/actions.tsx b/apps/web/src/app/payments/topup/success/actions.tsx index 7952ea643c..f1e23ecca1 100644 --- a/apps/web/src/app/payments/topup/success/actions.tsx +++ b/apps/web/src/app/payments/topup/success/actions.tsx @@ -8,6 +8,8 @@ import { captureMessage } from '@sentry/nextjs'; import { inArray } from 'drizzle-orm'; import type Stripe from 'stripe'; import { getAndClearPaymentReturnUrl } from '@/lib/payment-return-url'; +import { getOrganizationById } from '@/lib/organizations/organizations'; +import { getOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils'; export async function fetchCreditTransactionIdForStripeSession(sessionId: string) { console.info( @@ -47,7 +49,15 @@ export async function fetchCreditTransactionIdForStripeSession(sessionId: string console.info(`No credit transaction found for session ${sessionId}`); } - return creditTransaction; + if (!creditTransaction?.organization_id) { + return creditTransaction; + } + + const organization = await getOrganizationById(creditTransaction.organization_id); + return { + ...creditTransaction, + organizationRouteIdentifier: organization ? getOrganizationRouteIdentifier(organization) : null, + }; } export async function getPaymentReturnUrl(): Promise { diff --git a/apps/web/src/app/payments/topup/success/page.test.ts b/apps/web/src/app/payments/topup/success/page.test.ts index ef55ef41d4..81e0be2236 100644 --- a/apps/web/src/app/payments/topup/success/page.test.ts +++ b/apps/web/src/app/payments/topup/success/page.test.ts @@ -39,6 +39,18 @@ describe('getRedirectUrl', () => { ).toBe('/organizations/66333c77-6ac2-4a14-9278-3d45d67e87ec?topup-amount-usd=20'); }); + it('uses the organization route identifier when one is available', () => { + expect( + getRedirectUrl( + { + ...makeTransaction({ organization_id: '66333c77-6ac2-4a14-9278-3d45d67e87ec' }), + organizationRouteIdentifier: 'acme', + }, + null + ) + ).toBe('/organizations/acme?topup-amount-usd=20'); + }); + it('shows pending status when the ledger transaction is delayed', () => { expect(getRedirectUrl(undefined, null)).toBe('/credits?topup-status=pending'); }); diff --git a/apps/web/src/app/payments/topup/success/page.tsx b/apps/web/src/app/payments/topup/success/page.tsx index 5c33d8e9e0..f7e373785e 100644 --- a/apps/web/src/app/payments/topup/success/page.tsx +++ b/apps/web/src/app/payments/topup/success/page.tsx @@ -14,9 +14,13 @@ import { } from '@/lib/organizations/constants'; import { PageContainer } from '@/components/layouts/PageContainer'; +type TopUpCreditTransaction = CreditTransaction & { + organizationRouteIdentifier?: string | null; +}; + const MAX_TRANSACTION_LOOKUP_ATTEMPTS = 15; -export function getRedirectUrl(txn: CreditTransaction | undefined, returnUrl: string | null) { +export function getRedirectUrl(txn: TopUpCreditTransaction | undefined, returnUrl: string | null) { if (returnUrl) { return returnUrl; } @@ -31,7 +35,7 @@ export function getRedirectUrl(txn: CreditTransaction | undefined, returnUrl: st return `/credits?${params.toString()}`; } params.set(TOPUP_AMOUNT_QUERY_STRING_KEY, fromMicrodollars(txn.amount_microdollars).toString()); - return `/organizations/${txn.organization_id}?${params.toString()}`; + return `/organizations/${txn.organizationRouteIdentifier ?? txn.organization_id}?${params.toString()}`; } export default function TopUpSuccessPage() { @@ -44,7 +48,7 @@ export default function TopUpSuccessPage() { const returnUrlPromise = getPaymentReturnUrl().catch(() => null); const findCreditTransaction = async ( attempt: number - ): Promise => { + ): Promise => { if (attempt >= MAX_TRANSACTION_LOOKUP_ATTEMPTS || cancelled) return undefined; await new Promise(resolve => setTimeout(resolve, attempt * 100)); diff --git a/apps/web/src/app/users/accept-invite/[token]/page.tsx b/apps/web/src/app/users/accept-invite/[token]/page.tsx index 2ea714ba11..14badabb6a 100644 --- a/apps/web/src/app/users/accept-invite/[token]/page.tsx +++ b/apps/web/src/app/users/accept-invite/[token]/page.tsx @@ -4,6 +4,7 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { acceptOrganizationInvite } from '@/lib/organizations/organizations'; import { ensureHasValidStytch } from '@/lib/user'; +import { getOrganizationAppPath } from '@/lib/organizations/organization-route-utils'; type AcceptInvitePageProps = { params: Promise<{ token: string }>; @@ -29,7 +30,10 @@ export default async function AcceptInvitePage({ params }: AcceptInvitePageProps await ensureHasValidStytch(user.id); // now that the user has signed up (or in) redirect to the standard after sign up page // for possible stitch validation etc - const redirectTo = `/organizations/${result.organizationId}`; + const redirectTo = getOrganizationAppPath({ + id: result.organizationId, + slug: result.organizationSlug, + }); redirect(redirectTo); } diff --git a/apps/web/src/components/admin-omnibox/AdminOmnibox.tsx b/apps/web/src/components/admin-omnibox/AdminOmnibox.tsx index 6b23c34599..a0b7a4c705 100644 --- a/apps/web/src/components/admin-omnibox/AdminOmnibox.tsx +++ b/apps/web/src/components/admin-omnibox/AdminOmnibox.tsx @@ -21,6 +21,8 @@ import { createActionRegistry, filterRegistry } from './action-registry'; import type { OmniboxContext, OmniboxActionGroup } from './types'; import { Shield, User, MapPin, ExternalLink, Info, Zap, Building2 } from 'lucide-react'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; +import { useTRPC } from '@/lib/trpc/utils'; +import { useQuery } from '@tanstack/react-query'; // Build version - using a constant for now, could be injected at build time const BUILD_VERSION = 'dev'; @@ -68,10 +70,19 @@ type AdminOmniboxInnerProps = { function AdminOmniboxInner({ open, setOpen }: AdminOmniboxInnerProps) { const pathname = usePathname(); + const trpc = useTRPC(); const { data: user } = useUser(); const { setAssumedRole, assumedRole, originalRole } = useRoleTesting(); - const organizationId = extractOrganizationId(pathname); + const organizationRouteIdentifier = extractOrganizationId(pathname); + const { data: resolvedOrganization } = useQuery( + trpc.organizations.resolveRouteIdentifier.queryOptions( + { routeIdentifier: organizationRouteIdentifier ?? '' }, + { enabled: Boolean(organizationRouteIdentifier) } + ) + ); + const organizationId = resolvedOrganization?.id ?? null; + const organizationAdminRouteIdentifier = resolvedOrganization?.routeIdentifier ?? null; // Handle role change const handleRoleChange = useCallback( @@ -182,9 +193,9 @@ function AdminOmniboxInner({ open, setOpen }: AdminOmniboxInnerProps) { Admin Panel - {organizationId && ( + {organizationAdminRouteIdentifier && ( setOpen(false)} > diff --git a/apps/web/src/components/app-builder/AppBuilderLanding.tsx b/apps/web/src/components/app-builder/AppBuilderLanding.tsx index 7e34547f75..391c169bd3 100644 --- a/apps/web/src/components/app-builder/AppBuilderLanding.tsx +++ b/apps/web/src/components/app-builder/AppBuilderLanding.tsx @@ -48,6 +48,7 @@ import { InsufficientBalanceBanner } from '@/components/shared/InsufficientBalan import { PromptInput } from '@/components/app-builder/PromptInput'; import { TemplateGallery } from '@/components/app-builder/TemplateGallery'; import type { Images } from '@/lib/images-schema'; +import { getOrganizationAppPathForRouteIdentifier } from '@/lib/organizations/organization-route-utils'; import { type AppBuilderGalleryTemplate, APP_BUILDER_TEMPLATE_ASK_PROMPT, @@ -78,6 +79,7 @@ function sanitizeProjectTitle(title: string): string { type AppBuilderLandingProps = { organizationId?: string; + organizationRouteIdentifier?: string; onProjectCreated: (projectId: string, prompt: string) => void; }; @@ -248,17 +250,22 @@ function AllProjectsSheet({ userProjects, allProjects, organizationId, + organizationRouteIdentifier, children, }: { userProjects: Project[]; allProjects: Project[]; organizationId?: string; + organizationRouteIdentifier?: string; children: React.ReactNode; }) { const [search, setSearch] = useState(''); const [viewMode, setViewMode] = useState('user'); const [deletingProjectId, setDeletingProjectId] = useState(null); - const basePath = organizationId ? `/organizations/${organizationId}/app-builder` : '/app-builder'; + const organizationPathIdentifier = organizationRouteIdentifier ?? organizationId; + const basePath = organizationPathIdentifier + ? getOrganizationAppPathForRouteIdentifier(organizationPathIdentifier, '/app-builder') + : '/app-builder'; const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -383,14 +390,19 @@ function ProjectsSection({ allProjects, isLoading, organizationId, + organizationRouteIdentifier, }: { userProjects: Project[] | undefined; allProjects: Project[] | undefined; isLoading: boolean; organizationId?: string; + organizationRouteIdentifier?: string; }) { const [deletingProjectId, setDeletingProjectId] = useState(null); - const basePath = organizationId ? `/organizations/${organizationId}/app-builder` : '/app-builder'; + const organizationPathIdentifier = organizationRouteIdentifier ?? organizationId; + const basePath = organizationPathIdentifier + ? getOrganizationAppPathForRouteIdentifier(organizationPathIdentifier, '/app-builder') + : '/app-builder'; // For landing page, always show user's projects (or all projects for personal context) const displayProjects = userProjects; const recentProjects = displayProjects?.slice(0, MAX_RECENT_PROJECTS); @@ -478,6 +490,7 @@ function ProjectsSection({ userProjects={userProjects || []} allProjects={allProjects || []} organizationId={organizationId} + organizationRouteIdentifier={organizationRouteIdentifier} > + + + {slugError ?

{slugError}

: null} + + ) : ( +
+

+ {info.slug || Not set} +

+ {canEditSlug && ( + + )} +
+ )} + +
+ +

{id}

+
{isEditingDomain ? ( @@ -437,7 +596,7 @@ function Inner(props: InnerProps) { {auto_top_up_enabled && isAutoTopUpEnabled && ( Auto Top-up: On @@ -447,7 +606,9 @@ function Inner(props: InnerProps) {
{expiringBlocks.length > 0 && earliestExpiry && ( @@ -465,7 +626,7 @@ function Inner(props: InnerProps) {
{readonly ? 'View ' : 'Configure '} providers and models diff --git a/apps/web/src/components/organizations/OrganizationUsageSummaryCard.tsx b/apps/web/src/components/organizations/OrganizationUsageSummaryCard.tsx index dd5969c0bb..cba12530af 100644 --- a/apps/web/src/components/organizations/OrganizationUsageSummaryCard.tsx +++ b/apps/web/src/components/organizations/OrganizationUsageSummaryCard.tsx @@ -62,7 +62,13 @@ function MetricWithTooltip({ ); } -export function OrganizationUsageSummaryCard({ organizationId }: { organizationId: string }) { +export function OrganizationUsageSummaryCard({ + organizationId, + organizationRouteIdentifier, +}: { + organizationId: string; + organizationRouteIdentifier?: string; +}) { const { error, data: usage_stats, @@ -108,7 +114,6 @@ export function OrganizationUsageSummaryCard({ organizationId }: { organizationI const averageRequestsPerDay = Math.round((usage_stats.totalRequestCount / 30) * 10) / 10; const averageInputTokensPerDay = Math.round((usage_stats.totalInputTokens / 30) * 10) / 10; const averageOutputTokensPerDay = Math.round((usage_stats.totalOutputTokens / 30) * 10) / 10; - return ( @@ -190,15 +195,17 @@ export function OrganizationUsageSummaryCard({ organizationId }: { organizationI />
- + {organizationRouteIdentifier ? ( + + ) : null} diff --git a/apps/web/src/components/organizations/SeatUsageCard.tsx b/apps/web/src/components/organizations/SeatUsageCard.tsx index d8d3a66c49..4a825c972d 100644 --- a/apps/web/src/components/organizations/SeatUsageCard.tsx +++ b/apps/web/src/components/organizations/SeatUsageCard.tsx @@ -13,12 +13,14 @@ import { useOrganizationTrialStatus, useOrganizationWithMembers, } from '@/app/api/organizations/hooks'; +import { getOrganizationRouteIdentifier } from '@/lib/organizations/organization-route-utils'; type Props = { organizationId: string; + organizationRouteIdentifier?: string; }; -export function SeatUsageCard({ organizationId }: Props) { +export function SeatUsageCard({ organizationId, organizationRouteIdentifier }: Props) { const currentUserRole = useUserOrganizationRole(); const { @@ -64,6 +66,7 @@ export function SeatUsageCard({ organizationId }: Props) { const { usedSeats, totalSeats } = seatUsage; const isTrial = status !== 'subscribed'; + const routeIdentifier = organizationRouteIdentifier ?? getOrganizationRouteIdentifier(org); // Hide seat usage card when trial messaging is suppressed (e.g., OSS program participants) if (org.settings.suppress_trial_messaging) { @@ -85,7 +88,9 @@ export function SeatUsageCard({ organizationId }: Props) { {(currentUserRole === 'owner' || currentUserRole === 'billing_manager') && ( diff --git a/apps/web/src/components/organizations/new/CreateOrganizationPage.tsx b/apps/web/src/components/organizations/new/CreateOrganizationPage.tsx index a7f60ac6ee..281e278912 100644 --- a/apps/web/src/components/organizations/new/CreateOrganizationPage.tsx +++ b/apps/web/src/components/organizations/new/CreateOrganizationPage.tsx @@ -26,6 +26,7 @@ import { motion, AnimatePresence } from 'motion/react'; import { useCreateOrganization } from '@/app/api/organizations/hooks'; import { SubscriptionsSeatQuantitySchema } from '@/app/payments/subscriptions/types'; import Link from 'next/link'; +import { getOrganizationAppPath } from '@/lib/organizations/organization-route-utils'; const CreateOrganizationSchema = z.object({ organizationName: OrganizationNameSchema, @@ -116,16 +117,14 @@ export function CreateOrganizationPage({ mockSelectedOrgName }: CreateOrganizati } try { - const orgId = ( - await createOrganizationMutation.mutateAsync({ - name: validationResult.data.organizationName, - autoAddCreator: true, - company_domain: companyDomain.trim() || undefined, - }) - ).organization.id; + const { organization } = await createOrganizationMutation.mutateAsync({ + name: validationResult.data.organizationName, + autoAddCreator: true, + company_domain: companyDomain.trim() || undefined, + }); // Redirect with query param that will force users to invite a single user. - window.location.href = `/organizations/${orgId}/welcome?firstTime=1`; + window.location.href = `${getOrganizationAppPath(organization, '/welcome')}?firstTime=1`; } catch (error) { console.error('Failed to create organization:', error); setErrors({ diff --git a/apps/web/src/components/organizations/subscription/SubscriptionQuickActions.tsx b/apps/web/src/components/organizations/subscription/SubscriptionQuickActions.tsx index d7f7f4c034..cd6d8e497b 100644 --- a/apps/web/src/components/organizations/subscription/SubscriptionQuickActions.tsx +++ b/apps/web/src/components/organizations/subscription/SubscriptionQuickActions.tsx @@ -18,6 +18,7 @@ import { seatPrice } from '@/lib/organizations/constants'; import { useOrganizationReadOnly } from '@/lib/organizations/use-organization-read-only'; import { formatDate, canManageBilling, findPaidSeatItem } from './utils'; import type { SubscriptionWithPeriod } from './types'; +import { getOrganizationAppPath } from '@/lib/organizations/organization-route-utils'; export function SubscriptionQuickActions({ subscription, @@ -95,6 +96,7 @@ export function SubscriptionQuickActions({ const canChangeBillingCycle = canCancelSubscription && !hasPendingSchedule; const periodEnd = (subscription as SubscriptionWithPeriod).current_period_end; const effectiveDateLabel = periodEnd ? formatDate(periodEnd) : null; + const paymentHistoryPath = org.data ? getOrganizationAppPath(org.data, '/payment-details') : null; return ( <> @@ -141,16 +143,18 @@ export function SubscriptionQuickActions({ Update Payment Method - + {paymentHistoryPath ? ( + + ) : null} {canCancelSubscription && (