mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
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:
parent
041c31a613
commit
fb4038a969
59 changed files with 3531 additions and 517 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
78
ui/src/components/layout/SidebarTeamSwitcher.tsx
Normal file
78
ui/src/components/layout/SidebarTeamSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
302
ui/src/components/layout/__tests__/SidebarTeamSwitcher.test.tsx
Normal file
302
ui/src/components/layout/__tests__/SidebarTeamSwitcher.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
5
ui/src/lib/browserReload.ts
Normal file
5
ui/src/lib/browserReload.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
export function reloadApp() {
|
||||
window.location.reload();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue