fix: fix org scoped access for resources (#517)

* fix: fix org scoped access for resources

* Fix auth and config validation regressions

* fix: track org config validation timestamp

* fix: backfill org model configuration v2 from legacy user rows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: align config tests with org-level v2 resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: helm example values tweaks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Abhishek 2026-07-09 23:04:33 +05:30 committed by GitHub
parent 041c31a613
commit fb4038a969
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 3531 additions and 517 deletions

View file

@ -1,9 +1,14 @@
import { Loader2 } from "lucide-react";
export default function SpinLoader(){
return(
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-15 h-15 animate-spin" />
</div>
)
interface SpinLoaderProps {
label?: string;
}
export default function SpinLoader({ label }: SpinLoaderProps) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin text-foreground" />
{label && <span>{label}</span>}
</div>
);
}

View file

@ -1,6 +1,5 @@
"use client";
import type { Team } from "@stackframe/stack";
import {
AlertTriangle,
ArrowUpCircle,
@ -25,9 +24,10 @@ import {
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React, { useRef } from "react";
import React from "react";
import { BrandLogo } from "@/components/BrandLogo";
import { SidebarTeamSwitcher } from "@/components/layout/SidebarTeamSwitcher";
import ThemeToggle from "@/components/ThemeSwitcher";
import { Button } from "@/components/ui/button";
import {
@ -153,18 +153,11 @@ const NAV_SECTIONS: SidebarNavSection[] = [
},
];
// Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context
const StackTeamSwitcher = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({
default: mod.SelectedTeamSwitcher,
}))
);
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const { state, isMobile, setOpenMobile } = useSidebar();
const { provider, getSelectedTeam, logout, user } = useAuth();
const { provider, logout, user } = useAuth();
const { config } = useAppConfig();
const { openHireExpert } = useLeadForms();
const {
@ -176,16 +169,6 @@ export function AppSidebar() {
vonageMissingSignatureSecretCount > 0;
const isCollapsed = !isMobile && state === "collapsed";
// Get selected team for Stack auth (cast to Team type from Stack)
// Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes,
// preventing unnecessary PATCH calls to Stack Auth on every route navigation.
const selectedTeamRef = useRef<Team | null>(null);
const rawSelectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null;
if (rawSelectedTeam?.id !== selectedTeamRef.current?.id) {
selectedTeamRef.current = rawSelectedTeam;
}
const selectedTeam = selectedTeamRef.current;
// Version info from app config context
const versionInfo = config ? { ui: config.uiVersion, api: config.apiVersion } : null;
@ -397,18 +380,7 @@ export function AppSidebar() {
{provider === "stack" && (
<div className={cn("mt-3 notranslate", isCollapsed && "hidden")} translate="no">
<React.Suspense
fallback={
<div className="h-9 w-full animate-pulse rounded bg-muted" />
}
>
<StackTeamSwitcher
selectedTeam={selectedTeam || undefined}
onChange={() => {
router.refresh();
}}
/>
</React.Suspense>
<SidebarTeamSwitcher />
</div>
)}
</SidebarHeader>

View file

@ -0,0 +1,78 @@
"use client";
import type { CurrentUser, Team } from "@stackframe/stack";
import React, { useState } from "react";
import { toast } from "sonner";
import SpinLoader from "@/components/SpinLoader";
import { useAuth } from "@/lib/auth";
import { reloadApp } from "@/lib/browserReload";
import logger from "@/lib/logger";
// Lazy load Stack's SelectedTeamSwitcher, but own the selected-team write here.
// The stock component re-applies the selectedTeam prop asynchronously, which
// can pin the sidebar to the previous team while the user switch is in flight.
const StackTeamSwitcher = React.lazy(() =>
import("@stackframe/stack").then((mod) => ({
default: mod.SelectedTeamSwitcher,
}))
);
export function SidebarTeamSwitcher() {
const { provider, user } = useAuth();
// The !user guard is load-bearing (Sentry JAVASCRIPT-NEXTJS-2Z): Stack's
// TeamSwitcher calls user?.useTeams() — a hook behind optional chaining — so
// if useUser() flips to null mid-session (token expiry, sign-out from
// another tab) its re-render throws React #300 "Rendered fewer hooks than
// expected". Unmounting it here re-renders the ancestor first, removing the
// switcher before it can re-render with a null user.
if (provider !== "stack" || !user) {
return null;
}
return <SidebarTeamSwitcherContent user={user as CurrentUser} />;
}
function SidebarTeamSwitcherContent({ user }: { user: CurrentUser }) {
const [isSwitching, setIsSwitching] = useState(false);
const handleChange = async (team: Team | null) => {
setIsSwitching(true);
try {
await user.setSelectedTeam(team);
reloadApp();
} catch (error) {
logger.error("Failed to switch Stack team", error);
toast.error("Could not switch teams. Please try again.");
setIsSwitching(false);
}
};
return (
<div className="relative">
<React.Suspense
fallback={<div className="h-9 w-full animate-pulse rounded bg-muted" />}
>
<StackTeamSwitcher
selectedTeam={user.selectedTeam || undefined}
noUpdateSelectedTeam
onChange={(team) => {
void handleChange(team);
}}
triggerClassName="w-full"
/>
</React.Suspense>
{isSwitching && (
<div
className="fixed inset-0 z-[100] flex min-h-screen items-center justify-center bg-background/90 backdrop-blur-sm"
role="status"
aria-live="polite"
>
<SpinLoader label="Switching teams..." />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,302 @@
/**
* Regression tests for Sentry issue JAVASCRIPT-NEXTJS-2Z:
* "Rendered fewer hooks than expected" (React error #300).
*
* Stack's TeamSwitcher Inner component calls `user?.useTeams()` a hook behind
* optional chaining. When Stack's useUser() flips from a user object to null
* mid-session (token expiry, sign-out synced from another tab), the re-render
* calls fewer hooks than the previous one and React throws.
*
* These tests fake useUser()/useStackApp() but render the REAL
* SelectedTeamSwitcher component code from @stackframe/stack, so the first test
* pins the upstream bug: if it starts failing after a @stackframe/stack
* upgrade, the bug is fixed upstream and the guard in SidebarTeamSwitcher can
* be removed.
*
* Mocking note: vi.mock("@stackframe/stack") fully replaces the package entry.
* The real component is imported below via its dist file path its internal
* `import ... from "../index.js"` resolves to the same entry module and
* therefore receives the mocked hooks.
*/
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AuthUser } from "@/lib/auth";
import {
AuthContext,
type AuthContextType,
} from "@/lib/auth/providers/AuthProvider";
import { SelectedTeamSwitcher as RealSelectedTeamSwitcher } from "../../../../node_modules/@stackframe/stack/dist/esm/components/selected-team-switcher.js";
import { TranslationContext } from "../../../../node_modules/@stackframe/stack/dist/esm/providers/translation-provider-client.js";
import { SidebarTeamSwitcher } from "../SidebarTeamSwitcher";
const reloadAppMock = vi.hoisted(() => vi.fn());
const toastErrorMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/browserReload", () => ({
reloadApp: reloadAppMock,
}));
vi.mock("sonner", () => ({
toast: {
error: toastErrorMock,
},
}));
// Controls what the faked Stack useUser() returns; tests flip this to null to
// simulate the session dying (token expiry / sign-out from another tab).
const stackState: { user: ReturnType<typeof makeStackUser> | null } = {
user: null,
};
const TEAM_ONE = { id: "team-1", displayName: "Team One", profileImageUrl: null };
const TEAM_TWO = { id: "team-2", displayName: "Team Two", profileImageUrl: null };
const makeTeams = () => [TEAM_ONE, TEAM_TWO];
type MockTeam = typeof TEAM_ONE | typeof TEAM_TWO;
function makeStackUser() {
return {
id: "user-1",
selectedTeam: TEAM_ONE as MockTeam | null,
// The real CurrentUser.useTeams() consumes React hooks internally (Stack's
// async cache). Mirror that so the hook count matches real behavior — the
// crash only reproduces if useTeams actually registers hooks. Use useMemo
// (the same hook kind as the next hook in TeamSwitcher's Inner) so the
// user→null transition completes the render with leftover hooks and React
// throws the production error (#300) instead of a hook-slot TypeError.
useTeams: () => React.useMemo(() => makeTeams(), []),
setSelectedTeam: vi.fn(async (team: MockTeam | null) => {
if (stackState.user) {
stackState.user.selectedTeam = team;
}
}),
};
}
const mockStackApp = {
useNavigate: () => () => {},
useProject: () => ({ config: { clientTeamCreationEnabled: false } }),
urls: { accountSettings: "/account-settings" },
};
// Full replacement of the package entry. The factory only creates closures, so
// it is safe to execute during import hoisting; the consts above are read at
// render time. The raw upstream test imports the real SelectedTeamSwitcher by
// dist path; this package export is a small controlled stand-in for
// SidebarTeamSwitcher's lazy import.
vi.mock("@stackframe/stack", () => ({
useStackApp: () => mockStackApp,
useUser: () => stackState.user,
SelectedTeamSwitcher: (props: {
selectedTeam?: MockTeam | null;
onChange?: (team: MockTeam) => void;
triggerClassName?: string;
}) => (
<button
aria-controls="team-options"
aria-expanded="false"
className={props.triggerClassName}
role="combobox"
onClick={() => props.onChange?.(TEAM_TWO)}
>
{props.selectedTeam?.displayName ?? "Select team"}
</button>
),
}));
class CatchBoundary extends React.Component<
{ onError: (error: Error) => void; children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: Error) {
this.props.onError(error);
}
render() {
return this.state.failed ? (
<div data-testid="crashed" />
) : (
this.props.children
);
}
}
const translationValue = {
quetzalKeys: new Map<string, string>(),
quetzalLocale: new Map<string, string>(),
};
function withProviders(
children: React.ReactNode,
authValue?: AuthContextType
) {
const inner = authValue ? (
<AuthContext.Provider value={authValue}>{children}</AuthContext.Provider>
) : (
children
);
return (
<TranslationContext.Provider value={translationValue}>
{inner}
</TranslationContext.Provider>
);
}
function makeAuthValue(user: AuthUser | null): AuthContextType {
return {
user,
isAuthenticated: !!user,
loading: false,
getAccessToken: async () => "token",
redirectToLogin: () => {},
logout: async () => {},
provider: "stack",
getSelectedTeam: () => null,
};
}
describe("SidebarTeamSwitcher", () => {
beforeEach(() => {
stackState.user = makeStackUser();
reloadAppMock.mockClear();
toastErrorMock.mockClear();
// React logs boundary-caught render errors; keep test output readable.
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("pins the upstream Stack bug: raw SelectedTeamSwitcher crashes with React #300 when the user flips to null", async () => {
const errors: Error[] = [];
const { rerender } = render(
withProviders(
<CatchBoundary onError={(e) => errors.push(e)}>
<RealSelectedTeamSwitcher />
</CatchBoundary>
)
);
expect(await screen.findByRole("combobox")).toBeTruthy();
stackState.user = null;
rerender(
withProviders(
<CatchBoundary onError={(e) => errors.push(e)}>
<RealSelectedTeamSwitcher />
</CatchBoundary>
)
);
expect(
errors.some((e) => /Rendered fewer hooks than expected/.test(e.message)),
`expected React #300, got: ${errors.map((e) => e.message).join("; ") || "no error"}`
).toBe(true);
});
it("renders the switcher for an authenticated stack user", async () => {
render(
withProviders(
<SidebarTeamSwitcher />,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
expect((await screen.findByRole("combobox")).textContent).toBe("Team One");
});
it("blocks the app and reloads after Stack persists the selected team", async () => {
let finishSwitch!: () => void;
stackState.user!.setSelectedTeam = vi.fn(async (team: MockTeam | null) => {
await new Promise<void>((resolve) => {
finishSwitch = () => {
if (stackState.user) {
stackState.user.selectedTeam = team;
}
resolve();
};
});
});
render(
withProviders(
<SidebarTeamSwitcher />,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
const switcher = await screen.findByRole("combobox");
fireEvent.click(switcher);
expect((await screen.findByRole("status")).textContent).toContain("Switching teams...");
expect(reloadAppMock).not.toHaveBeenCalled();
expect(stackState.user?.setSelectedTeam).toHaveBeenCalledWith(TEAM_TWO);
await act(async () => {
finishSwitch();
});
await waitFor(() => {
expect(reloadAppMock).toHaveBeenCalled();
});
});
it("shows feedback and does not reload when Stack fails to persist the selected team", async () => {
stackState.user!.setSelectedTeam = vi.fn(async () => {
throw new Error("Stack write failed");
});
render(
withProviders(
<SidebarTeamSwitcher />,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
const switcher = await screen.findByRole("combobox");
fireEvent.click(switcher);
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(
"Could not switch teams. Please try again."
);
});
expect(screen.queryByRole("status")).toBeNull();
expect(reloadAppMock).not.toHaveBeenCalled();
});
it("unmounts the switcher instead of crashing when the session dies (user flips to null)", async () => {
const errors: Error[] = [];
const { rerender } = render(
withProviders(
<CatchBoundary onError={(e) => errors.push(e)}>
<SidebarTeamSwitcher />
</CatchBoundary>,
makeAuthValue(stackState.user as unknown as AuthUser)
)
);
expect(await screen.findByRole("combobox")).toBeTruthy();
// In production both changes arrive in the same update: Stack's internal
// store empties, which updates useUser() consumers AND our AuthContext
// (StackAuthContextProvider derives its value from the same store).
stackState.user = null;
rerender(
withProviders(
<CatchBoundary onError={(e) => errors.push(e)}>
<SidebarTeamSwitcher />
</CatchBoundary>,
makeAuthValue(null)
)
);
expect(errors.map((e) => e.message)).toEqual([]);
expect(screen.queryByRole("combobox")).toBeNull();
});
});

View file

@ -1,9 +1,8 @@
'use client';
import { StackClientApp, StackProvider, StackTheme, useUser as useStackUser } from '@stackframe/stack';
import { StackClientApp, StackProvider, StackTheme } from '@stackframe/stack';
import React, { useMemo, useRef } from 'react';
import type { AuthUser } from '../types';
import { AuthContext } from './AuthProvider';
// Create a singleton StackClientApp instance to prevent multiple initializations
@ -35,17 +34,19 @@ interface StackProviderWrapperProps {
publishableClientKey: string;
}
// Simple context provider that uses Stack's useUser directly
function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
const stackUser = useStackUser();
function StackAuthContextProvider({
children,
app,
}: {
children: React.ReactNode;
app: StackClientApp<true, string>;
}) {
const stackUser = app.useUser();
// Store user in ref for callbacks to access latest value without creating new callbacks
const userRef = useRef(stackUser);
userRef.current = stackUser;
// Derive loading state: loading if we don't have a user yet
const isLoading = stackUser === null;
// Stable callbacks that use ref to access current user
const getAccessToken = React.useCallback(async () => {
const user = userRef.current;
@ -95,21 +96,17 @@ function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
}
}, []);
// IMPORTANT: Use primitive values (userId, isLoading) in deps, NOT stackUser object
// Stack's useUser() returns a new object reference on every render, which would cause infinite re-renders
const userId = stackUser?.id;
const contextValue = useMemo(() => ({
user: userRef.current as AuthUser,
isAuthenticated: !!userId,
loading: isLoading,
user: stackUser,
isAuthenticated: !!stackUser,
loading: false,
getAccessToken,
redirectToLogin,
logout,
provider: 'stack' as const,
getSelectedTeam,
listPermissions,
}), [userId, isLoading, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
}), [stackUser, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
return (
<AuthContext.Provider value={contextValue}>
@ -130,7 +127,7 @@ export function StackProviderWrapper({ children, projectId, publishableClientKey
return (
<StackProvider app={stackClientApp} translationOverrides={translationOverrides}>
<StackTheme>
<StackAuthContextProvider>
<StackAuthContextProvider app={stackClientApp}>
{children}
</StackAuthContextProvider>
</StackTheme>

View file

@ -0,0 +1,81 @@
import { render, screen } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAuth } from "../AuthProvider";
import { StackProviderWrapper } from "../StackProviderWrapper";
type MockStackUser = {
id: string;
selectedTeam: null;
getAuthJson: () => Promise<{ accessToken: string }>;
};
const stackState = vi.hoisted(() => ({
user: null as MockStackUser | null,
}));
vi.mock("@stackframe/stack", () => {
class MockStackClientApp {
useUser = vi.fn(() => stackState.user);
constructor(
public options: {
projectId: string;
publishableClientKey: string;
}
) {}
}
return {
StackClientApp: MockStackClientApp,
StackProvider: ({ children }: { children: React.ReactNode }) => children,
StackTheme: ({ children }: { children: React.ReactNode }) => children,
};
});
function AuthProbe() {
const auth = useAuth();
return (
<div data-testid="auth-state">
{auth.loading ? "loading" : auth.user?.id ?? "signed-out"}
</div>
);
}
function makeUser(): MockStackUser {
return {
id: "stack-user-1",
selectedTeam: null,
getAuthJson: async () => ({ accessToken: "token" }),
};
}
describe("StackProviderWrapper", () => {
beforeEach(() => {
stackState.user = makeUser();
});
it("updates AuthContext when Stack's reactive user becomes null", () => {
const props = {
projectId: "00000000-0000-4000-8000-000000000000",
publishableClientKey: "publishable-client-key",
};
const { rerender } = render(
<StackProviderWrapper {...props}>
<AuthProbe />
</StackProviderWrapper>
);
expect(screen.getByTestId("auth-state").textContent).toBe("stack-user-1");
stackState.user = null;
rerender(
<StackProviderWrapper {...props}>
<AuthProbe />
</StackProviderWrapper>
);
expect(screen.getByTestId("auth-state").textContent).toBe("signed-out");
});
});

View file

@ -0,0 +1,5 @@
"use client";
export function reloadApp() {
window.location.reload();
}