dograh/ui/src/lib/auth/providers/StackProviderWrapper.tsx
2026-07-09 18:18:44 +05:30

136 lines
3.9 KiB
TypeScript

'use client';
import { StackClientApp, StackProvider, StackTheme } from '@stackframe/stack';
import React, { useMemo, useRef } from 'react';
import { AuthContext } from './AuthProvider';
// Create a singleton StackClientApp instance to prevent multiple initializations
let stackClientAppInstance: StackClientApp<true, string> | null = null;
function getStackClientApp(
projectId: string,
publishableClientKey: string,
): StackClientApp<true, string> {
if (!stackClientAppInstance) {
// projectId / publishableClientKey are passed explicitly (fetched from the
// backend at runtime) instead of being read from inlined NEXT_PUBLIC_* env,
// so the prebuilt image works without build-time configuration.
stackClientAppInstance = new StackClientApp({
tokenStore: "nextjs-cookie",
projectId,
publishableClientKey,
urls: {
afterSignIn: "/after-sign-in"
}
});
}
return stackClientAppInstance;
}
interface StackProviderWrapperProps {
children: React.ReactNode;
projectId: string;
publishableClientKey: string;
}
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;
// Stable callbacks that use ref to access current user
const getAccessToken = React.useCallback(async () => {
const user = userRef.current;
if (!user) {
throw new Error('User not authenticated');
}
const authJson = await user.getAuthJson();
if (!authJson.accessToken) {
throw new Error('No access token available');
}
return authJson.accessToken;
}, []);
const redirectToLogin = React.useCallback(() => {
if (typeof window !== 'undefined') {
window.location.href = '/handler/sign-in';
}
}, []);
const logout = React.useCallback(async () => {
// Redirect to Stack's server-side sign-out handler instead of calling
// signOut() client-side. Client-side signOut triggers an internal
// re-render that causes a hooks ordering violation in Stack's components.
if (typeof window !== 'undefined') {
window.location.href = '/handler/sign-out';
}
}, []);
const getSelectedTeam = React.useCallback(() => {
return userRef.current?.selectedTeam ?? null;
}, []);
const listPermissions = React.useCallback(async (team?: unknown) => {
const user = userRef.current;
if (!user?.listPermissions) {
return [];
}
const targetTeam = team || user.selectedTeam;
if (!targetTeam) {
return [];
}
try {
const perms = await user.listPermissions(targetTeam);
return Array.isArray(perms) ? perms : [];
} catch {
return [];
}
}, []);
const contextValue = useMemo(() => ({
user: stackUser,
isAuthenticated: !!stackUser,
loading: false,
getAccessToken,
redirectToLogin,
logout,
provider: 'stack' as const,
getSelectedTeam,
listPermissions,
}), [stackUser, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}
const translationOverrides = {
"Email": "Business Email",
"Sign in with {provider}": "Sign in with {provider} Business",
"Sign up with {provider}": "Sign up with {provider} Business",
};
export function StackProviderWrapper({ children, projectId, publishableClientKey }: StackProviderWrapperProps) {
const stackClientApp = getStackClientApp(projectId, publishableClientKey);
return (
<StackProvider app={stackClientApp} translationOverrides={translationOverrides}>
<StackTheme>
<StackAuthContextProvider app={stackClientApp}>
{children}
</StackAuthContextProvider>
</StackTheme>
</StackProvider>
);
}